From aa6fabe9702013a65ab095fdf54b0bbe01a967ed Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 00:47:33 +0300 Subject: [PATCH 1/5] Fix search lease terminology and empty reviews --- README.md | 15 +- backend/README.md | 6 +- backend/search/api.py | 6 +- backend/search/evaluate_relevance.py | 2 +- backend/search/minjust_opensearch.py | 4 +- backend/search/query.py | 20 ++- backend/search/review.html | 14 +- backend/test_search_relevance.py | 25 +++ backend/test_search_reviews.py | 30 ++++ .../search-relevance-findings-2026-11-03.md | 142 ++++++++++++++++++ 10 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 docs/product/search-relevance-findings-2026-11-03.md 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..b8207ae 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-7" LANGUAGES = {"ru", "ky"} CODE = re.compile(r"^[0-9]+$") MAX_PAGE_SIZE = 100 @@ -286,6 +286,8 @@ class Api: by_rank = {item["rank"]: item for item in snapshot["results"]} if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank): raise ApiError(400, "all search results must be reviewed exactly once") + 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 +302,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..0e9e75f 100644 --- a/backend/search/query.py +++ b/backend/search/query.py @@ -22,6 +22,18 @@ 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"(? list[str]: pattern = re.compile(rf"(? 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 @@

Документ

Выберите документ в выдаче, чтобы открыть текст.

Содержание
- +

Черновик появится после поиска.

Вход в Акылдаш

Введите учетные данные, выданные администратором.

Создайте новый пароль

Для продолжения замените временный пароль. Используйте не менее 12 символов.

@@ -675,7 +675,7 @@

Документ

Источники документов

Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.

Официальный источник: Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики.

Короткое знакомство

- + diff --git a/backend/test_search_relevance.py b/backend/test_search_relevance.py index 510efc0..db26506 100644 --- a/backend/test_search_relevance.py +++ b/backend/test_search_relevance.py @@ -49,6 +49,31 @@ 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) + + for query in ( + "аренда квартиры", + "расторжение договора аренды квартиры", + "договор аренды земельного участка", + "договор поднайма", + "наем работника", + "договор лизинга", + ): + self.assertEqual(query_variants("ru", query), [query]) + + 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..be7ef68 100644 --- a/backend/test_search_reviews.py +++ b/backend/test_search_reviews.py @@ -13,6 +13,8 @@ class SearchReviewTest(unittest.TestCase): 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}]}) @@ -30,6 +32,34 @@ class SearchReviewTest(unittest.TestCase): 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] + 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)) 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..263958c --- /dev/null +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -0,0 +1,142 @@ +# Диагностика релевантности поиска — 3 ноября 2026 года + +## Решения и границы + +Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление +терминологии и контракт для закрытия пробела корпуса. Она не утверждает, что +поиск уже стал юридически релевантнее: новая выдача должна быть проверена +отдельно на изолированном стенде и сравнена с исходными снимками. Полный набор +из 7 500 запросов повторно не собирался. + +Изменён backend `0.13.3` → `0.13.4`, frontend внутренней лаборатории +`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-7`. + +## Исходный 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 коротких документов прочитаны целиком; по ним сохранено 79 оценок пар +«запрос—документ» в 71 сессии: 59 нерелевантных и 20 косвенно полезных. +Неоценённые результаты не считались ошибками. + +| Тема | Наблюдавшийся неверный приоритет | Вывод | +|---|---:|---| +| Расторжение договора аренды | 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 теперь сохраняет исходную строку и добавляет только для точного +сочетания «договор [арендная форма]» вариант «договор [та же форма] +имущественного найма». В частности, `договора аренды` превращается в +`договора имущественного найма`. Расширение не срабатывает для аренды квартиры +или земельного участка, поднайма, найма работника или лизинга. Точный запрос остаётся в строгой +лексической ветке, поэтому его профильный муниципальный акт не выкидывается из +поиска. Фактический top-k после изменения и влияние на соседние запросы требуют +отдельного A/B прогона; кодовый тест проверяет только варианты и DSL. + +## Пустые выдачи в лаборатории + +Исходный контракт блокировал сохранение шести подписанных пустых снимков: +API отвергал `results: []`, а интерфейс читал поля из пустого placeholder. +Теперь signed snapshot может сохранить пустую оценку с обязательным общим +комментарием. В записи `top_result_code` остаётся `null`, список результатов +пустой. Непустой снимок по-прежнему обязан получить оценку каждого ранга ровно +один раз, совпадающую с подписанным кодом документа. HMAC, TTL и текущая +аутентификация не меняются. + +Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых +сессии, 79 экспертных оценок и 55 516 диагностических комментариев; 74 715 +оценок результатов остались пустыми намеренно. Integrity check прошёл, +порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не +переписывались; шесть пустых сессий должны быть размечены отдельно после +выпуска интерфейса. + +## Пробел актуального корпуса и безопасный контракт закрытия + +Отдельно от ранжирования, в проверенном тестовом индексе не было трёх +подтверждённых действующих актов (все три 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 | + +В индексе присутствуют прежние отменённые трудовой и земельный кодексы; нельзя +переименовать или поднять их как замену отсутствующим актуальным нормам. +Подготовленные для отдельной проверки точные RU/KY тексты — 3 документа и +8 934 фрагмента — пока не составляют полного корпуса и не подключены к +работающему индексу. Вложения и история редакций отдельно не подтверждены. + +Закрытие пробела требует отдельного контролируемого обновления данных: + +1. Заново сверить официальные canonical ID, статус, edition ID и дату; сохранить + исходный ответ, хеши и provenance в отдельном staging-каталоге. +2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и + вложения; не импортировать метаданные с ненужными персональными полями. +3. Нормализовать отдельно от текущего корпуса и проверить counts/хеши; затем + пересобрать полный версионный индекс из базового корпуса и дополнений. +4. На изолированном стенде проверить наличие актуальных canonical ID, открытие + документа/редакции через API, активность статуса, RU/KY поиск и контрольные + запросы к прежним источникам. Не применять поправку как прямую запись в + действующий индекс и не переключать alias до полного сравнения. +5. Публикация требует отдельного владельческого разрешения и проверенного + rollback. Эта PR не загружает документы, не меняет индекс или production. + +## Что остаётся проверить + +- Root-owned изолированный прогон на 8081 должен проверить реальный top-k новой + терминологической ветки и регрессии на контрольных примерах. +- Полный benchmark после изменения нужен для нового snapshot и сравнения; он + выполняется отдельно от исходной сбора 7 500 выдач. +- Актуальные три акта требуют отдельной проверки полноты/лицензии/истории и + контролируемой полной пересборки корпуса. Их отсутствие не решается + ранжированием. +- Независимое ревью и CI этой PR не заменяют экспертную оценку правового ответа. + +Исходные измерения и хеши хранятся вне репозитория в рабочем наборе +`search-benchmark-2026-11-02`. В этот репозиторий не включаются сырые ответы +MinJust, диагностические логи, внешние пользовательские данные или выгрузка +лабораторных комментариев. -- 2.54.0 From 726b1ee74578d2754dd56ab875f5c4969ad135fa Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 01:09:01 +0300 Subject: [PATCH 2/5] Harden empty review validation and lease ranking --- backend/search/api.py | 9 ++- backend/search/query.py | 27 +++++--- backend/test_search_api_opensearch.py | 10 +++ backend/test_search_relevance.py | 7 +- backend/test_search_reviews.py | 43 ++++++++++++ .../search-relevance-findings-2026-11-03.md | 69 ++++++++++++++----- tools/frontend-visual-review/check.cjs | 31 ++++++++- 7 files changed, 166 insertions(+), 30 deletions(-) diff --git a/backend/search/api.py b/backend/search/api.py index b8207ae..6f94a21 100644 --- a/backend/search/api.py +++ b/backend/search/api.py @@ -283,8 +283,15 @@ 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") diff --git a/backend/search/query.py b/backend/search/query.py index 0e9e75f..f0f936b 100644 --- a/backend/search/query.py +++ b/backend/search/query.py @@ -31,9 +31,10 @@ LEASE_TERM_ALIAS = re.compile( ) LEASE_TERM_ALIAS_EXCLUSIONS = re.compile( r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|" - r"подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b", + r"поднайм\w*|подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b", re.IGNORECASE, ) +LEASE_TERM_PHRASE_BOOST = 12 COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000)) TAX_PROFIT_DOCUMENTS = (("112340", 3000),) BOILERPLATE_PREFIXES = { @@ -74,18 +75,22 @@ def relaxed_query_text(language: str, query: str) -> 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} diff --git a/backend/test_search_api_opensearch.py b/backend/test_search_api_opensearch.py index a0f6232..26b697d 100644 --- a/backend/test_search_api_opensearch.py +++ b/backend/test_search_api_opensearch.py @@ -37,5 +37,15 @@ 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") + + if __name__ == "__main__": unittest.main() diff --git a/backend/test_search_relevance.py b/backend/test_search_relevance.py index db26506..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): @@ -58,17 +58,22 @@ class SearchRelevanceTest(unittest.TestCase): 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", "договору аренды"), ["договору аренды", "договору имущественного найма"], diff --git a/backend/test_search_reviews.py b/backend/test_search_reviews.py index be7ef68..44178fd 100644 --- a/backend/test_search_reviews.py +++ b/backend/test_search_reviews.py @@ -18,6 +18,49 @@ class SearchReviewTest(unittest.TestCase): 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") diff --git a/docs/product/search-relevance-findings-2026-11-03.md b/docs/product/search-relevance-findings-2026-11-03.md index 263958c..67ce0d7 100644 --- a/docs/product/search-relevance-findings-2026-11-03.md +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -34,9 +34,12 @@ MRR, полноту ответа или юридическую правильн ## Подтверждённые классы ошибок -14 коротких документов прочитаны целиком; по ним сохранено 79 оценок пар -«запрос—документ» в 71 сессии: 59 нерелевантных и 20 косвенно полезных. -Неоценённые результаты не считались ошибками. +14 коротких документов прочитаны целиком; Codex (ИИ) подготовил по ним 79 +предварительных оценок пар «запрос—документ» в 71 сессии: 59 помечены как +нерелевантные и 20 как косвенно полезные. Это тематическая AI-разметка для +триажа, а не заключение юриста или независимая экспертная проверка. Она не +подтверждает полноту правового ответа. Неоценённые результаты не считались +ошибками. | Тема | Наблюдавшийся неверный приоритет | Вывод | |---|---:|---| @@ -76,6 +79,19 @@ Backend теперь сохраняет исходную строку и доб поиска. Фактический top-k после изменения и влияние на соседние запросы требуют отдельного A/B прогона; кодовый тест проверяет только варианты и DSL. +В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся +с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066 +оставался первым. Поэтому новая ревизия усиливает только `match_phrase` для +синтетического варианта с юридическим термином (`boost=12`); исходная фраза +оставляет исходный вес 3. Это проверяемая гипотеза, а не подтверждённая починка: +текущий commit нужно повторно прогнать на изолированном индексе. Критерий +приёмки: для общего запроса код 5 — top 1; для явной формулировки «имущественный +наём» он остаётся top 1; для запросов о жилом помещении, земельном участке, +поднайме, трудовых отношениях, международном арбитраже и кыргызских запросов +расширение отсутствует, а контрольная выдача не ухудшается относительно +baseline. Вес не должен подниматься автоматически, если этот критерий не +выполняется. + ## Пустые выдачи в лаборатории Исходный контракт блокировал сохранение шести подписанных пустых снимков: @@ -87,7 +103,7 @@ API отвергал `results: []`, а интерфейс читал поля и аутентификация не меняются. Существующая отдельная база локальной лаборатории содержала 7 494 сохранённых -сессии, 79 экспертных оценок и 55 516 диагностических комментариев; 74 715 +сессии, 79 AI-оценок и 55 516 диагностических комментариев; 74 715 оценок результатов остались пустыми намеренно. Integrity check прошёл, порядок кодов и редакций baseline не менялся. Эти сохранённые оценки не переписывались; шесть пустых сессий должны быть размечены отдельно после @@ -104,11 +120,19 @@ API отвергал `results: []`, а интерфейс читал поля и | Земельный кодекс КР от 18.07.2025 № 149 | 230029465 | 34746 от 18.07.2025 | | Об охране здоровья граждан КР от 12.01.2024 № 14 | 230000918 | 47553 от 23.01.2026 | -В индексе присутствуют прежние отменённые трудовой и земельный кодексы; нельзя -переименовать или поднять их как замену отсутствующим актуальным нормам. -Подготовленные для отдельной проверки точные RU/KY тексты — 3 документа и -8 934 фрагмента — пока не составляют полного корпуса и не подключены к -работающему индексу. Вложения и история редакций отдельно не подтверждены. +Это разрыв между нормализованными документами и индексом, а не полное +отсутствие актов в локальном архиве: в `data/minjust-normalized` уже есть +canonical-каталоги всех трёх документов, manifest сообщает 209 958 успешно +обработанных документов. Однако трудовой акт там отстаёт от официальной +редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026); +земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье +последняя подготовленная — № 60 от 23.01.2026. В индекс эти три canonical ID не +попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать +или поднять как замену актуальным нормам. + +Для отдельной проверки подготовлены точные официальные RU/KY редакции: 3 +документа и 8 934 фрагмента. Это добавление пока не подключено к индексу или +API. Вложения и полная история новых canonical ID отдельно не подтверждены. Закрытие пробела требует отдельного контролируемого обновления данных: @@ -116,19 +140,32 @@ API отвергал `results: []`, а интерфейс читал поля и исходный ответ, хеши и provenance в отдельном staging-каталоге. 2. Проверить полноту обеих языковых версий, ссылки на редакции, историю и вложения; не импортировать метаданные с ненужными персональными полями. -3. Нормализовать отдельно от текущего корпуса и проверить counts/хеши; затем - пересобрать полный версионный индекс из базового корпуса и дополнений. -4. На изолированном стенде проверить наличие актуальных canonical ID, открытие - документа/редакции через API, активность статуса, RU/KY поиск и контрольные - запросы к прежним источникам. Не применять поправку как прямую запись в - действующий индекс и не переключать alias до полного сравнения. +3. Нормализовать документы отдельно и проверить exact-count/hash. Для + ограниченного дополнения собрать отдельный версионный индекс только из этих + актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов. +4. На изолированном стенде подключить read-only представление трёх новых + каталогов к API `data_root` и искать через выражение + `текущий-индекс,supplement-index`. URL-кодирование запятой для такого + выражения проверено read-only на API. Проверить наличие canonical ID, + открытие новой редакции RU/KY, статус и даты, collapse по документу, а также + контрольную выдачу по старым индексам. Не создавать записи в основном + индексе и не менять alias до принятия результата. 5. Публикация требует отдельного владельческого разрешения и проверенного rollback. Эта PR не загружает документы, не меняет индекс или production. ## Что остаётся проверить - Root-owned изолированный прогон на 8081 должен проверить реальный top-k новой - терминологической ветки и регрессии на контрольных примерах. + терминологической ветки и baseline-сравнение контрольных примеров. +- Целевой read-only integration gate, который не создаёт и не удаляет индекс; + прогон выполняет root на выделенном стенде: + + ```bash + AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \ + AKYLDASH_OPENSEARCH_INDEX=akyldash-fragments-v2 \ + PYTHONPATH=backend python3 -m unittest \ + backend.test_search_api_opensearch.SearchRankingOpenSearchTest.test_generic_lease_query_ranks_general_civil_code_first -v + ``` - Полный benchmark после изменения нужен для нового snapshot и сравнения; он выполняется отдельно от исходной сбора 7 500 выдач. - Актуальные три акта требуют отдельной проверки полноты/лицензии/истории и 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(); -- 2.54.0 From e653f3b50e3af7bdf1fbf059467bf2cb5435e7df Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 01:41:58 +0300 Subject: [PATCH 3/5] Preserve multi-index scope in review snapshots --- backend/search/api.py | 9 ++++- backend/test_search_reviews.py | 36 ++++++++++++++++++- .../search-relevance-findings-2026-11-03.md | 18 +++++++++- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/backend/search/api.py b/backend/search/api.py index 6f94a21..43f025c 100644 --- a/backend/search/api.py +++ b/backend/search/api.py @@ -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 diff --git a/backend/test_search_reviews.py b/backend/test_search_reviews.py index 44178fd..0ffd9cc 100644 --- a/backend/test_search_reviews.py +++ b/backend/test_search_reviews.py @@ -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": "Юрист", diff --git a/docs/product/search-relevance-findings-2026-11-03.md b/docs/product/search-relevance-findings-2026-11-03.md index 67ce0d7..4dcccb8 100644 --- a/docs/product/search-relevance-findings-2026-11-03.md +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -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. ## Что остаётся проверить -- 2.54.0 From 1c7ddf2fcbd7c55920470e7ac52339fdab0366db Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 02:20:23 +0300 Subject: [PATCH 4/5] Use DFS scoring for explicit multi-index search --- backend/search/api.py | 12 +- backend/test_search_api.py | 39 +++++++ backend/test_search_api_opensearch.py | 14 +++ backend/test_search_reviews.py | 23 +++- .../search-relevance-findings-2026-11-03.md | 105 ++++++++++++++---- 5 files changed, 169 insertions(+), 24 deletions(-) diff --git a/backend/search/api.py b/backend/search/api.py index 43f025c..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-7" +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: 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 26b697d..e4ffa42 100644 --- a/backend/test_search_api_opensearch.py +++ b/backend/test_search_api_opensearch.py @@ -47,5 +47,19 @@ class SearchRankingOpenSearchTest(unittest.TestCase): 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_reviews.py b/backend/test_search_reviews.py index 0ffd9cc..83358f1 100644 --- a/backend/test_search_reviews.py +++ b/backend/test_search_reviews.py @@ -3,7 +3,7 @@ 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): @@ -148,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 index 4dcccb8..da84cb4 100644 --- a/docs/product/search-relevance-findings-2026-11-03.md +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -3,13 +3,15 @@ ## Решения и границы Эта заметка фиксирует исходные измерения от 2 ноября, узкое исправление -терминологии и контракт для закрытия пробела корпуса. Она не утверждает, что -поиск уже стал юридически релевантнее: новая выдача должна быть проверена -отдельно на изолированном стенде и сравнена с исходными снимками. Полный набор -из 7 500 запросов повторно не собирался. +терминологии, условный 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-7`. +`0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7` +относится к предшествующему составу ranking semantics; новые подписанные +снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются. ## Исходный benchmark @@ -74,23 +76,88 @@ Backend теперь сохраняет исходную строку и доб сочетания «договор [арендная форма]» вариант «договор [та же форма] имущественного найма». В частности, `договора аренды` превращается в `договора имущественного найма`. Расширение не срабатывает для аренды квартиры -или земельного участка, поднайма, найма работника или лизинга. Точный запрос остаётся в строгой -лексической ветке, поэтому его профильный муниципальный акт не выкидывается из -поиска. Фактический top-k после изменения и влияние на соседние запросы требуют -отдельного A/B прогона; кодовый тест проверяет только варианты и DSL. +или земельного участка, поднайма, найма работника или лизинга. Точный запрос +остаётся в строгой лексической ветке, поэтому его профильный муниципальный акт +не исключается из поиска. В read-only пробе предыдущей версии `aa6fabe` для общего запроса код 5 поднялся с позиции за пределами top 10 на #5, но нерелевантный индивидуальный акт 135066 -оставался первым. Поэтому новая ревизия усиливает только `match_phrase` для -синтетического варианта с юридическим термином (`boost=12`); исходная фраза -оставляет исходный вес 3. Это проверяемая гипотеза, а не подтверждённая починка: -текущий commit нужно повторно прогнать на изолированном индексе. Критерий -приёмки: для общего запроса код 5 — top 1; для явной формулировки «имущественный -наём» он остаётся top 1; для запросов о жилом помещении, земельном участке, -поднайме, трудовых отношениях, международном арбитраже и кыргызских запросов -расширение отсутствует, а контрольная выдача не ухудшается относительно -baseline. Вес не должен подниматься автоматически, если этот критерий не -выполняется. +оставался первым. На 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 SHA нужен отдельный прогон; контроль запросов об +аренде жилья/земли, поднайме, трудовых отношениях, международном праве и 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 сохранил этот акт первым. Результат по пяти +педагогическим/научным запросам с медицинским актом на первом месте и по +запросу о болезнях животных не исправлен; это межтематические lexical overlaps, +а не основание вводить глобальные веса или специальные document ID. +Сравнение ограничено выборкой и не является разметкой юридической правильности. +Медианы 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 +``` + +Тест выполняет только поиск и проверяет подписанный scope; он не создаёт и не +удаляет индексы. Root запускает его только при подтверждении, что указанный +supplement ещё существует и доступен на read-only runtime. Полный benchmark на +7 500 запросах нужен для итогового pinned SHA с тем же frozen baseline. +300-query эксперимент и предыдущие benchmarks не следует выдавать за +результат нового SHA. + +Откат к предшествующему приложению возвращает `query_then_fetch` для multi-index +поиска. Индекс, документы, saved reviews и старые подписанные токены при этом +не требуют изменений или миграции; новые отзывы, сохранённые до rollback, +остаются помечены `search-8`. Для supplement действует отдельный rollback +контракт ниже: отключение его подключения скрывает только добавленные редакции, +не удаляя подготовленные файлы и базовый индекс. ## Пустые выдачи в лаборатории -- 2.54.0 From 849c19e2ab1805466c42419bcd4cff2d47882ed6 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 3 Nov 2026 02:38:36 +0300 Subject: [PATCH 5/5] docs: record final search benchmark findings --- CHANGELOG.md | 10 ++ .../search-relevance-findings-2026-11-03.md | 160 ++++++++++++------ 2 files changed, 118 insertions(+), 52 deletions(-) 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/docs/product/search-relevance-findings-2026-11-03.md b/docs/product/search-relevance-findings-2026-11-03.md index da84cb4..41ff8b9 100644 --- a/docs/product/search-relevance-findings-2026-11-03.md +++ b/docs/product/search-relevance-findings-2026-11-03.md @@ -12,6 +12,8 @@ `0.3.3` → `0.3.4`, версия алгоритма `search-6` → `search-8`. `search-7` относится к предшествующему составу ranking semantics; новые подписанные снимки этого PR помечаются `search-8`. Сохранённые отзывы не переписываются. +Финальный runtime/batch прогон кода выполнен на `1c7ddf2`; последующий коммит +этого отчёта и changelog меняет только документацию. ## Исходный benchmark @@ -89,10 +91,10 @@ Backend теперь сохраняет исходную строку и доб фразы, исходный phrase boost 3; он не описывает multi-index DFS. После подключения трёх актов в отдельном supplement на `e653f3b` изменились 184 выдачи против code-only варианта и 38 top-1; все шесть пустых выдач -сохранились. Разные наборы индексов нельзя считать одним benchmark. Для -итогового conditional-DFS SHA нужен отдельный прогон; контроль запросов об -аренде жилья/земли, поднайме, трудовых отношениях, международном праве и KY -формулировках остаётся обязательным. Rank-метрики не являются юридической +сохранились. Разные наборы индексов нельзя считать одним benchmark. Итоговый +conditional-DFS прогон на `1c7ddf2` приведён ниже. Контроль запросов об аренде +жилья/земли, поднайме, трудовых отношениях, международном праве и KY +формулировках выполнялся отдельно; rank-метрики не являются юридической оценкой. ## Условный scoring для явного multi-index scope @@ -117,11 +119,14 @@ multi-index конфигурации и не меняет behavior одиноч одном неизменном наборе данных: 300 заранее выбранных запросов, максимум 300. Порядок менялся в 93 случаях, top-1 — в 29; из 9 именованных риск-кейсов изменились 3, все 5 выбранных positive controls сохранили top-1. Контроль с -индивидуальным актом 135066 сохранил этот акт первым. Результат по пяти -педагогическим/научным запросам с медицинским актом на первом месте и по -запросу о болезнях животных не исправлен; это межтематические lexical overlaps, -а не основание вводить глобальные веса или специальные document ID. -Сравнение ограничено выборкой и не является разметкой юридической правильности. +индивидуальным актом 135066 сохранил этот акт первым. В пяти запросах о +педагогических/научных работниках и одном запросе о болезнях животных top-1 не +изменился. Эти шесть случаев требуют предметной оценки и не объявляются +автоматически нерелевантными: статья 150(8) закона об охране здоровья связывает +аттестацию педагогических/научных работников с медицинской деятельностью, а +случай с болезнями животных остаётся без экспертной оценки. Новые оценки пар не +выставлялись; специальные веса или document IDs не добавлялись. Сравнение +ограничено выборкой и не является разметкой юридической правильности. Медианы 22,955 мс для default и 16,115 мс для DFS не используются как оценка скорости: default всегда исполнялся первым, возможен cache/order bias. @@ -145,12 +150,11 @@ PYTHONPATH=backend python3 -m unittest \ backend.test_search_api_opensearch.SearchMultiIndexDfsOpenSearchTest.test_global_statistics_mode_accepts_read_only_multi_index_search -v ``` -Тест выполняет только поиск и проверяет подписанный scope; он не создаёт и не -удаляет индексы. Root запускает его только при подтверждении, что указанный -supplement ещё существует и доступен на read-only runtime. Полный benchmark на -7 500 запросах нужен для итогового pinned SHA с тем же frozen baseline. -300-query эксперимент и предыдущие benchmarks не следует выдавать за -результат нового SHA. +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 и старые подписанные токены при этом @@ -159,6 +163,61 @@ supplement ещё существует и доступен на read-only runtim контракт ниже: отключение его подключения скрывает только добавленные редакции, не удаляя подготовленные файлы и базовый индекс. +## Итоговый 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 не включены. + ## Пустые выдачи в лаборатории Исходный контракт блокировал сохранение шести подписанных пустых снимков: @@ -185,12 +244,14 @@ SQLite-схема, исторические записи и формат под сессии, 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 | Последняя проверенная редакция | @@ -205,13 +266,16 @@ canonical-каталоги всех трёх документов, manifest со обработанных документов. Однако трудовой акт там отстаёт от официальной редакции 57548 от 28.07.2026 (последняя подготовленная — № 60 от 23.06.2026); земельный акт нормализован до редакции № 10 от 18.07.2025; для акта о здоровье -последняя подготовленная — № 60 от 23.01.2026. В индекс эти три canonical ID не -попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать +последняя подготовленная — № 60 от 23.01.2026. В base index эти три canonical +ID не попали. Прежние отменённые трудовой и земельный кодексы нельзя переименовать или поднять как замену актуальным нормам. -Для отдельной проверки подготовлены точные официальные RU/KY редакции: 3 -документа и 8 934 фрагмента. Это добавление пока не подключено к индексу или -API. Вложения и полная история новых canonical ID отдельно не подтверждены. +Для локальной изолированной проверки подготовлены точные официальные RU/KY +редакции: 3 документа и 8 934 фрагмента. На runtime 8081 они были временно +подключены отдельным supplement-index и доступны API через read-only overlay +catalogues. Это не изменение base index или production и не доказательство +полноты корпуса. Вложения и полная история новых canonical ID отдельно не +подтверждены. Закрытие пробела требует отдельного контролируемого обновления данных: @@ -222,39 +286,31 @@ API. Вложения и полная история новых canonical ID о 3. Нормализовать документы отдельно и проверить exact-count/hash. Для ограниченного дополнения собрать отдельный версионный индекс только из этих актов; не пересобирать и не перезаписывать индекс из 42,6 млн фрагментов. -4. На изолированном стенде подключить read-only представление трёх новых - каталогов к API `data_root` и искать через выражение - `текущий-индекс,supplement-index`. URL-кодирование запятой для такого - выражения проверено read-only на API. Проверить наличие canonical ID, - открытие новой редакции RU/KY, статус и даты, collapse по документу, а также - контрольную выдачу по старым индексам. Не создавать записи в основном - индексе и не менять alias до принятия результата. +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 будет включён, rollback отключит - поиск и просмотр новых редакций из него; подготовленные файлы и уже - сохранённые отзывы при этом сохраняются, базовый индекс не удаляется. Эта PR - не загружает документы, не подключает offline editions к API и не меняет - индекс или production. + rollback. При отключении локального supplement-index поиск и просмотр новых + редакций из него прекращаются; подготовленные файлы и уже сохранённые отзывы + сохраняются, базовый индекс не удаляется. PR + фиксирует код и результаты локального испытания; production, base index и + исходные данные не менялись. ## Что остаётся проверить -- Root-owned изолированный прогон на 8081 должен проверить реальный top-k новой - терминологической ветки и baseline-сравнение контрольных примеров. -- Целевой read-only integration gate, который не создаёт и не удаляет индекс; - прогон выполняет root на выделенном стенде: - - ```bash - AKYLDASH_OPENSEARCH_URL=http://127.0.0.1:8081 \ - AKYLDASH_OPENSEARCH_INDEX=akyldash-fragments-v2 \ - PYTHONPATH=backend python3 -m unittest \ - backend.test_search_api_opensearch.SearchRankingOpenSearchTest.test_generic_lease_query_ranks_general_civil_code_first -v - ``` -- Полный benchmark после изменения нужен для нового snapshot и сравнения; он - выполняется отдельно от исходной сбора 7 500 выдач. -- Актуальные три акта требуют отдельной проверки полноты/лицензии/истории и - контролируемой полной пересборки корпуса. Их отсутствие не решается - ранжированием. -- Независимое ревью и CI этой PR не заменяют экспертную оценку правового ответа. +- Финальный 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`. В этот репозиторий не включаются сырые ответы -- 2.54.0