Compare commits
21 Commits
feature/se
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ad2e4b256 | |||
| 61681cde73 | |||
| 9781e93eaf | |||
| 601c7f085f | |||
| 69d4090f6d | |||
| 85672dc041 | |||
| 0f6f12e0e9 | |||
| 7a656cfc1a | |||
| 72de364f29 | |||
| 74873451ab | |||
| 2faac7b74e | |||
| e3f08426d5 | |||
| dc5399de0a | |||
| 8f0f0e3fbf | |||
| 9ef8b099df | |||
| 9318474a54 | |||
| d701f714e1 | |||
| 575f4fa3af | |||
| e6c4848a7f | |||
| 0a72d0d242 | |||
| d4ee8fac08 |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
|||||||
# История изменений
|
# История изменений
|
||||||
|
|
||||||
|
## Не выпущено
|
||||||
|
|
||||||
|
- Добавлено безопасное переключение alias на новую версию поискового индекса
|
||||||
|
после полной загрузки; checkpoint защищает возобновление загрузки от смены
|
||||||
|
alias.
|
||||||
|
- Добавлен roadmap готовности данных, поиска и API перед проектированием
|
||||||
|
frontend.
|
||||||
|
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
|
||||||
|
показываются действующие положение о регистрации и закон о хозяйственных
|
||||||
|
товариществах и обществах.
|
||||||
|
- Добавлен CLI `python3 -m search.query` для проверки текущей выдачи локального
|
||||||
|
OpenSearch.
|
||||||
|
- Добавлена инструкция по подготовке и независимой проверке relevance set.
|
||||||
|
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
|
||||||
|
текст документа как единое поле.
|
||||||
|
|
||||||
## 0.5.0 — 2026-08-15
|
## 0.5.0 — 2026-08-15
|
||||||
|
|
||||||
- Добавлена воспроизводимая оценка качества поиска по Recall@10 и MRR@10.
|
- Добавлена воспроизводимая оценка качества поиска по Recall@10 и MRR@10.
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||||
`0.5.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
|
`0.6.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
|
||||||
размеченном наборе запросов.
|
размеченном наборе запросов.
|
||||||
|
|
||||||
| Компонент | Версия | Состояние |
|
| Компонент | Версия | Состояние |
|
||||||
|---|---:|---|
|
|---|---:|---|
|
||||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||||
| Backend | `0.5.0` | добавлена воспроизводимая оценка качества поиска |
|
| Backend | `0.6.0` | добавлено атомарное переключение alias поискового индекса |
|
||||||
| Frontend | — | ещё не создан |
|
| Frontend | — | ещё не создан |
|
||||||
| Сбор и обработка правовых данных | `0.5.0` | добавлены relevance set и baseline-метрики |
|
| Сбор и обработка правовых данных | `0.6.0` | добавлены relevance set и baseline-метрики |
|
||||||
| RAG и база знаний | — | ещё не созданы |
|
| RAG и база знаний | — | ещё не созданы |
|
||||||
|
|
||||||
## Структура репозитория
|
## Структура репозитория
|
||||||
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Акылдаш
|
# Backend Акылдаш
|
||||||
|
|
||||||
Версия: `0.5.0`
|
Версия: `0.6.0`
|
||||||
|
|
||||||
Первая backend-область проекта — загрузка правовых документов из официального
|
Первая backend-область проекта — загрузка правовых документов из официального
|
||||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||||
@@ -128,6 +128,16 @@ python3 backend/search/minjust_opensearch.py \
|
|||||||
После проверки production-индекса следует переключать alias, чтобы удалённые
|
После проверки 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 и
|
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
|
||||||
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
|
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
|
||||||
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
|
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
|
||||||
@@ -142,8 +152,10 @@ python3 backend/search/minjust_opensearch.py \
|
|||||||
|
|
||||||
По умолчанию checkpoint хранится в
|
По умолчанию checkpoint хранится в
|
||||||
`data/opensearch/<index>.checkpoint.json`; путь можно изменить через
|
`data/opensearch/<index>.checkpoint.json`; путь можно изменить через
|
||||||
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`
|
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`,
|
||||||
и SHA-256 нормализованного manifest. Resume отклоняется при любом несовпадении:
|
`--alias` и SHA-256 нормализованного manifest. При `--resume` передавайте то же
|
||||||
|
значение `--alias`; старый checkpoint без alias можно продолжить только без
|
||||||
|
него. Resume отклоняется при любом несовпадении:
|
||||||
для обновлённого корпуса или пересозданного индекса нужно создать новый
|
для обновлённого корпуса или пересозданного индекса нужно создать новый
|
||||||
версионный индекс, проверить его и переключить alias. Это не оставляет
|
версионный индекс, проверить его и переключить alias. Это не оставляет
|
||||||
удалённые trailing-фрагменты старых документов.
|
удалённые trailing-фрагменты старых документов.
|
||||||
@@ -171,8 +183,9 @@ Security plugin отключён только для локальной разр
|
|||||||
25 кыргызских запросов. Для каждого запроса человек должен указать реальную
|
25 кыргызских запросов. Для каждого запроса человек должен указать реальную
|
||||||
формулировку и коды всех релевантных документов; пустая или неполная разметка
|
формулировку и коды всех релевантных документов; пустая или неполная разметка
|
||||||
должна быть отклонена при ручной проверке, а технически некорректная — самим
|
должна быть отклонена при ручной проверке, а технически некорректная — самим
|
||||||
оценщиком. Рабочую копию следует хранить в игнорируемом каталоге `data/`,
|
оценщиком. Критерии выбора запросов, релевантности и двойной проверки описаны в
|
||||||
пока набор не проверен и не разрешён к публикации.
|
`search/RELEVANCE_ANNOTATION.md`. Рабочую копию следует хранить в игнорируемом
|
||||||
|
каталоге `data/`, пока набор не проверен и не разрешён к публикации.
|
||||||
|
|
||||||
Baseline использует поля названия и текста соответствующего языка, оставляет в
|
Baseline использует поля названия и текста соответствующего языка, оставляет в
|
||||||
выдаче один результат на документ и вычисляет макро-средние Recall@10 и MRR@10:
|
выдаче один результат на документ и вычисляет макро-средние Recall@10 и MRR@10:
|
||||||
@@ -188,6 +201,13 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
|||||||
Менять веса или анализаторы следует только после фиксации этого baseline и
|
Менять веса или анализаторы следует только после фиксации этого baseline и
|
||||||
разбора ошибок выдачи.
|
разбора ошибок выдачи.
|
||||||
|
|
||||||
|
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=backend python3 -m search.query "как открыть ОсОО" --language ru
|
||||||
|
PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --language ky
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Iterable
|
from typing import Callable, Iterable
|
||||||
|
|
||||||
APP_VERSION = "0.5.0"
|
APP_VERSION = "0.6.0"
|
||||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
APP_VERSION = "0.5.0"
|
APP_VERSION = "0.6.0"
|
||||||
SCHEMA_VERSION = "1"
|
SCHEMA_VERSION = "1"
|
||||||
NORMALIZER_VERSION = "1.0.0"
|
NORMALIZER_VERSION = "1.0.0"
|
||||||
LANGUAGES = ("ru", "ky")
|
LANGUAGES = ("ru", "ky")
|
||||||
|
|||||||
106
backend/search/RELEVANCE_ANNOTATION.md
Normal file
106
backend/search/RELEVANCE_ANNOTATION.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Инструкция по разметке relevance set v1
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Набор проверяет, находит ли поиск нужные документы по реальным формулировкам
|
||||||
|
пользователей. Он не должен подгоняться под текущую выдачу: сначала фиксируются
|
||||||
|
запросы и релевантные документы, затем считается baseline и меняется
|
||||||
|
ранжирование.
|
||||||
|
|
||||||
|
## Подготовка
|
||||||
|
|
||||||
|
Создайте игнорируемую Git рабочую копию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p data/search
|
||||||
|
cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Сохраните идентификаторы `ru-01`–`ru-25` и `ky-01`–`ky-25`. Заполняйте
|
||||||
|
`query` и `relevant_document_codes`; остальные поля и структуру JSON не меняйте.
|
||||||
|
|
||||||
|
## Выбор запросов
|
||||||
|
|
||||||
|
- Используйте 25 русских и 25 кыргызских запросов, реально заданных или
|
||||||
|
сформулированных носителем языка для практической юридической задачи.
|
||||||
|
- Не переводите русский набор дословно на кыргызский: оба набора должны
|
||||||
|
отражать естественные формулировки своего языка.
|
||||||
|
- Записывайте исходную формулировку без улучшения под поисковик. Допустимы
|
||||||
|
разговорные слова, распространённые сокращения и опечатки.
|
||||||
|
- Не используйте персональные данные, закрытые материалы и сведения, которых
|
||||||
|
нет в публичном корпусе Минюста.
|
||||||
|
- Не включайте запрос, если нельзя установить хотя бы один релевантный документ.
|
||||||
|
- Не повторяйте один информационный запрос в нескольких близких формулировках.
|
||||||
|
|
||||||
|
Проверьте разнообразие набора: названия и номера актов, вопросы по жизненной
|
||||||
|
или рабочей ситуации, короткие тематические запросы, органы принятия, статусы и
|
||||||
|
даты. Это ориентир, а не квота: реальные запросы важнее искусственного баланса.
|
||||||
|
|
||||||
|
## Критерий релевантности
|
||||||
|
|
||||||
|
Документ релевантен, если его текст или реквизиты непосредственно отвечают
|
||||||
|
информационной потребности запроса. Добавляйте все такие документы, а не только
|
||||||
|
первый результат.
|
||||||
|
|
||||||
|
Не отмечайте документ релевантным только потому, что он:
|
||||||
|
|
||||||
|
- содержит отдельные слова запроса;
|
||||||
|
- упоминает нужный акт без ответа на запрос;
|
||||||
|
- относится к близкой теме;
|
||||||
|
- является утратившей силу редакцией, когда запрос явно требует действующую
|
||||||
|
норму, либо наоборот.
|
||||||
|
|
||||||
|
Если запрос допускает несколько самостоятельных правильных документов,
|
||||||
|
добавьте коды каждого из них. Код берите из поля `document_code`, а не из ID
|
||||||
|
фрагмента или редакции.
|
||||||
|
|
||||||
|
## Разметка одного запроса
|
||||||
|
|
||||||
|
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
|
||||||
|
`query`.
|
||||||
|
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
|
||||||
|
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
|
||||||
|
термином или известным номером акта.
|
||||||
|
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
|
||||||
|
кандидата.
|
||||||
|
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
|
||||||
|
критерию релевантности.
|
||||||
|
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
|
||||||
|
пропущенные альтернативные акты.
|
||||||
|
|
||||||
|
Пример структуры (код условный):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ru-01",
|
||||||
|
"language": "ru",
|
||||||
|
"query": "как зарегистрировать общественное объединение",
|
||||||
|
"relevant_document_codes": ["12345"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка качества
|
||||||
|
|
||||||
|
Второй человек должен проверить формулировку, язык и полный список релевантных
|
||||||
|
документов для каждого запроса. Спорные случаи обсуждаются до единого решения;
|
||||||
|
результат голосования или непроверенную разметку в baseline не включайте.
|
||||||
|
|
||||||
|
Перед запуском убедитесь, что:
|
||||||
|
|
||||||
|
- заполнены ровно 50 записей: 25 `ru` и 25 `ky`;
|
||||||
|
- все запросы непустые и различаются по информационной потребности;
|
||||||
|
- у каждой записи есть хотя бы один уникальный `document_code`;
|
||||||
|
- язык запроса совпадает с `language`;
|
||||||
|
- JSON не содержит комментариев и дополнительных полей.
|
||||||
|
|
||||||
|
Оценщик дополнительно проверит структуру файла. После ручной проверки
|
||||||
|
зафиксируйте копию набора и не меняйте её при настройке поиска:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||||
|
data/search/relevance-set-v1.json \
|
||||||
|
> data/search/baseline-v1.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Разбирайте запросы с низкими Recall@10 и MRR@10 по отдельности. Меняйте веса,
|
||||||
|
анализаторы или словари только после сохранения исходного baseline.
|
||||||
@@ -5,10 +5,10 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import urllib.parse
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from search.minjust_opensearch import APP_VERSION, request_json
|
from search.minjust_opensearch import APP_VERSION
|
||||||
|
from search.query import search_documents
|
||||||
|
|
||||||
|
|
||||||
def load_queries(path: Path) -> list[dict]:
|
def load_queries(path: Path) -> list[dict]:
|
||||||
@@ -43,30 +43,7 @@ def load_queries(path: Path) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
|
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
|
||||||
language = item["language"]
|
return search_documents(base_url, index, item["language"], item["query"], top_k)
|
||||||
body = json.dumps({
|
|
||||||
"size": top_k,
|
|
||||||
"track_total_hits": False,
|
|
||||||
"_source": ["document_code"],
|
|
||||||
"query": {
|
|
||||||
"bool": {
|
|
||||||
"filter": {"term": {"language": language}},
|
|
||||||
"must": {
|
|
||||||
"multi_match": {
|
|
||||||
"query": item["query"],
|
|
||||||
"fields": [f"document_name_{language}", f"text_{language}"],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"collapse": {"field": "document_code"},
|
|
||||||
}, ensure_ascii=False).encode()
|
|
||||||
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
|
|
||||||
response = request_json(url, "POST", body, "application/json")
|
|
||||||
try:
|
|
||||||
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
|
|
||||||
except (KeyError, TypeError) as error:
|
|
||||||
raise RuntimeError("OpenSearch search response is incomplete") from error
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:
|
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
APP_VERSION = "0.5.0"
|
APP_VERSION = "0.6.0"
|
||||||
LANGUAGES = {"ru", "ky"}
|
LANGUAGES = {"ru", "ky"}
|
||||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||||
|
|
||||||
@@ -246,6 +246,7 @@ def checkpoint_state(
|
|||||||
cluster_uuid: str,
|
cluster_uuid: str,
|
||||||
index_uuid: str,
|
index_uuid: str,
|
||||||
limit: int | None,
|
limit: int | None,
|
||||||
|
alias: str | None,
|
||||||
) -> tuple[dict, str | None]:
|
) -> tuple[dict, str | None]:
|
||||||
source = input_root.resolve()
|
source = input_root.resolve()
|
||||||
destination = checkpoint.resolve()
|
destination = checkpoint.resolve()
|
||||||
@@ -254,7 +255,7 @@ def checkpoint_state(
|
|||||||
manifest_sha256 = file_sha256(input_root / "manifest.sqlite3")
|
manifest_sha256 = file_sha256(input_root / "manifest.sqlite3")
|
||||||
if not resume:
|
if not resume:
|
||||||
return {
|
return {
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"url": url,
|
"url": url,
|
||||||
"cluster_uuid": cluster_uuid,
|
"cluster_uuid": cluster_uuid,
|
||||||
"index": index,
|
"index": index,
|
||||||
@@ -262,6 +263,7 @@ def checkpoint_state(
|
|||||||
"input": str(source),
|
"input": str(source),
|
||||||
"manifest_sha256": manifest_sha256,
|
"manifest_sha256": manifest_sha256,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
|
"alias": alias,
|
||||||
"last_document_code": None,
|
"last_document_code": None,
|
||||||
"complete": False,
|
"complete": False,
|
||||||
}, None
|
}, None
|
||||||
@@ -276,13 +278,22 @@ def checkpoint_state(
|
|||||||
"input",
|
"input",
|
||||||
"manifest_sha256",
|
"manifest_sha256",
|
||||||
"limit",
|
"limit",
|
||||||
|
"alias",
|
||||||
"last_document_code",
|
"last_document_code",
|
||||||
"complete",
|
"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}")
|
raise ValueError(f"Invalid checkpoint: {checkpoint}")
|
||||||
if (
|
if (
|
||||||
state["schema_version"] != 1
|
state["schema_version"] != 2
|
||||||
or state["url"] != url
|
or state["url"] != url
|
||||||
or state["cluster_uuid"] != cluster_uuid
|
or state["cluster_uuid"] != cluster_uuid
|
||||||
or state["index"] != index
|
or state["index"] != index
|
||||||
@@ -292,6 +303,8 @@ def checkpoint_state(
|
|||||||
raise ValueError(f"Checkpoint does not match this load: {checkpoint}")
|
raise ValueError(f"Checkpoint does not match this load: {checkpoint}")
|
||||||
if state["limit"] != limit:
|
if state["limit"] != limit:
|
||||||
raise ValueError(f"Checkpoint limit does not match --limit: {checkpoint}")
|
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:
|
if state["manifest_sha256"] != manifest_sha256:
|
||||||
raise ValueError("Normalized manifest changed; create a new versioned index")
|
raise ValueError("Normalized manifest changed; create a new versioned index")
|
||||||
if state["complete"] is not False:
|
if state["complete"] is not False:
|
||||||
@@ -311,8 +324,11 @@ def load_bulk(
|
|||||||
limit: int | None = None,
|
limit: int | None = None,
|
||||||
resume: bool = False,
|
resume: bool = False,
|
||||||
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
|
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
|
||||||
|
alias: str | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
base = url.rstrip("/")
|
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='')}"
|
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
|
||||||
if resume:
|
if resume:
|
||||||
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
|
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
|
||||||
@@ -328,6 +344,7 @@ def load_bulk(
|
|||||||
cluster_uuid,
|
cluster_uuid,
|
||||||
index_uuid,
|
index_uuid,
|
||||||
limit,
|
limit,
|
||||||
|
alias,
|
||||||
)
|
)
|
||||||
documents = document_count(input_root, limit, start_at)
|
documents = document_count(input_root, limit, start_at)
|
||||||
if not resume:
|
if not resume:
|
||||||
@@ -363,11 +380,31 @@ def load_bulk(
|
|||||||
state["last_document_code"] = last_code
|
state["last_document_code"] = last_code
|
||||||
write_json_atomic(checkpoint, state)
|
write_json_atomic(checkpoint, state)
|
||||||
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
|
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
|
||||||
|
if alias:
|
||||||
|
switch_alias(base, index, alias)
|
||||||
state["complete"] = True
|
state["complete"] = True
|
||||||
write_json_atomic(checkpoint, state)
|
write_json_atomic(checkpoint, state)
|
||||||
return documents, fragments
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--input", type=Path, default=Path("data/minjust-normalized"))
|
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("--index", default="akyldash-fragments-v1")
|
||||||
parser.add_argument("--limit", type=int)
|
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("--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("--mapping", type=Path, default=DEFAULT_MAPPING)
|
||||||
parser.add_argument("--batch-mb", type=int, default=25)
|
parser.add_argument("--batch-mb", type=int, default=25)
|
||||||
parser.add_argument("--resume", action="store_true", help="load into an existing index")
|
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")
|
raise SystemExit("--resume requires --url")
|
||||||
if arguments.checkpoint and not arguments.url:
|
if arguments.checkpoint and not arguments.url:
|
||||||
raise SystemExit("--checkpoint requires --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:
|
if arguments.url:
|
||||||
checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json"
|
checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json"
|
||||||
documents, fragments = load_bulk(
|
documents, fragments = load_bulk(
|
||||||
@@ -400,6 +442,7 @@ def main() -> int:
|
|||||||
arguments.limit,
|
arguments.limit,
|
||||||
arguments.resume,
|
arguments.resume,
|
||||||
checkpoint,
|
checkpoint,
|
||||||
|
arguments.alias,
|
||||||
)
|
)
|
||||||
destination = arguments.url
|
destination = arguments.url
|
||||||
else:
|
else:
|
||||||
|
|||||||
90
backend/search/query.py
Normal file
90
backend/search/query.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Run document searches against the local OpenSearch index."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
from search.minjust_opensearch import APP_VERSION, request_json
|
||||||
|
|
||||||
|
|
||||||
|
def company_registration_clauses(language: str, query: str) -> list[dict]:
|
||||||
|
patterns = {
|
||||||
|
"ru": (r"\bкак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\bкак\s+зарегистр\w*\s+осоо\b"),
|
||||||
|
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
|
||||||
|
}[language]
|
||||||
|
if not any(re.search(pattern, query.casefold()) for pattern in patterns):
|
||||||
|
return []
|
||||||
|
|
||||||
|
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
|
||||||
|
# ponytail: curated legal mapping; replace with a reviewed intent catalog when coverage expands.
|
||||||
|
def clause(document_code: str, boost: int) -> dict:
|
||||||
|
return {
|
||||||
|
"constant_score": {
|
||||||
|
"filter": {
|
||||||
|
"bool": {
|
||||||
|
"filter": [
|
||||||
|
{"term": {"document_code": document_code}},
|
||||||
|
{"term": {f"status_{language}": status}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"boost": boost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [clause("230044970", 2000), clause("667", 1000)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_search_body(language: str, query: str, top_k: int) -> bytes:
|
||||||
|
full_text = {
|
||||||
|
"multi_match": {
|
||||||
|
"query": query,
|
||||||
|
"fields": [f"document_name_{language}", f"text_{language}"],
|
||||||
|
"type": "cross_fields",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clauses = company_registration_clauses(language, query)
|
||||||
|
bool_query = {"filter": {"term": {"language": language}}}
|
||||||
|
if clauses:
|
||||||
|
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
|
||||||
|
else:
|
||||||
|
bool_query["must"] = full_text
|
||||||
|
return json.dumps({
|
||||||
|
"size": top_k,
|
||||||
|
"track_total_hits": False,
|
||||||
|
"_source": ["document_code"],
|
||||||
|
"query": {"bool": bool_query},
|
||||||
|
"collapse": {"field": "document_code"},
|
||||||
|
}, ensure_ascii=False).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def search_documents(base_url: str, index: str, language: str, query: str, top_k: int) -> list[str]:
|
||||||
|
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
|
||||||
|
response = request_json(url, "POST", build_search_body(language, query, top_k), "application/json")
|
||||||
|
try:
|
||||||
|
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
|
||||||
|
except (KeyError, TypeError) as error:
|
||||||
|
raise RuntimeError("OpenSearch search response is incomplete") from error
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("query")
|
||||||
|
parser.add_argument("--language", choices=("ru", "ky"), required=True)
|
||||||
|
parser.add_argument("--url", default="http://127.0.0.1:9200")
|
||||||
|
parser.add_argument("--index", default="akyldash-fragments-v1")
|
||||||
|
parser.add_argument("--top-k", type=int, default=10)
|
||||||
|
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
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 — not created")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -9,10 +9,27 @@ from contextlib import closing
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
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):
|
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):
|
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
@@ -91,6 +108,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
{"cluster_uuid": "cluster-1"},
|
{"cluster_uuid": "cluster-1"},
|
||||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||||
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
|
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
|
||||||
|
{"acknowledged": True},
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
load_bulk(
|
load_bulk(
|
||||||
@@ -99,14 +117,40 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
"test-index",
|
"test-index",
|
||||||
maximum_bytes=4096,
|
maximum_bytes=4096,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
|
alias="test-current",
|
||||||
),
|
),
|
||||||
(2, 2),
|
(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"))
|
state = json.loads(checkpoint.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(state["last_document_code"], "7")
|
self.assertEqual(state["last_document_code"], "7")
|
||||||
self.assertTrue(state["complete"])
|
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["last_document_code"] = "8"
|
||||||
state["complete"] = False
|
state["complete"] = False
|
||||||
checkpoint.write_text(json.dumps(state), encoding="utf-8")
|
checkpoint.write_text(json.dumps(state), encoding="utf-8")
|
||||||
@@ -116,6 +160,21 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||||
]
|
]
|
||||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
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(
|
load_bulk(
|
||||||
root / "normalized",
|
root / "normalized",
|
||||||
"http://127.0.0.1:9200",
|
"http://127.0.0.1:9200",
|
||||||
@@ -138,11 +197,13 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
limit=1,
|
limit=1,
|
||||||
resume=True,
|
resume=True,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
|
alias="test-current",
|
||||||
)
|
)
|
||||||
with patch("search.minjust_opensearch.request_json") as request:
|
with patch("search.minjust_opensearch.request_json") as request:
|
||||||
request.side_effect = [
|
request.side_effect = [
|
||||||
{"cluster_uuid": "cluster-1"},
|
{"cluster_uuid": "cluster-1"},
|
||||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||||
|
{"acknowledged": True},
|
||||||
]
|
]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
load_bulk(
|
load_bulk(
|
||||||
@@ -152,10 +213,12 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
maximum_bytes=4096,
|
maximum_bytes=4096,
|
||||||
resume=True,
|
resume=True,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
|
alias="test-current",
|
||||||
),
|
),
|
||||||
(1, 0),
|
(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["last_document_code"] = "9"
|
||||||
state["complete"] = False
|
state["complete"] = False
|
||||||
@@ -173,6 +236,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
maximum_bytes=4096,
|
maximum_bytes=4096,
|
||||||
resume=True,
|
resume=True,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
|
alias="test-current",
|
||||||
)
|
)
|
||||||
|
|
||||||
failed_checkpoint = root / "failed-checkpoint.json"
|
failed_checkpoint = root / "failed-checkpoint.json"
|
||||||
@@ -218,6 +282,7 @@ class MinjustOpenSearchTest(unittest.TestCase):
|
|||||||
maximum_bytes=4096,
|
maximum_bytes=4096,
|
||||||
resume=True,
|
resume=True,
|
||||||
checkpoint=checkpoint,
|
checkpoint=checkpoint,
|
||||||
|
alias="test-current",
|
||||||
)
|
)
|
||||||
|
|
||||||
http_error = urllib.error.HTTPError(
|
http_error = urllib.error.HTTPError(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from search.evaluate_relevance import evaluate, load_queries
|
from search.evaluate_relevance import evaluate, load_queries
|
||||||
|
from search.query import build_search_body
|
||||||
|
|
||||||
|
|
||||||
class SearchRelevanceTest(unittest.TestCase):
|
class SearchRelevanceTest(unittest.TestCase):
|
||||||
@@ -28,18 +29,47 @@ class SearchRelevanceTest(unittest.TestCase):
|
|||||||
path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8")
|
path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8")
|
||||||
loaded = load_queries(path)
|
loaded = load_queries(path)
|
||||||
|
|
||||||
responses = [
|
with patch("search.evaluate_relevance.search_documents", side_effect=[["7", "10", "8"], ["10", "9"]]):
|
||||||
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["7", "10", "8"]]}},
|
|
||||||
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["10", "9"]]}},
|
|
||||||
]
|
|
||||||
with patch("search.evaluate_relevance.request_json", side_effect=responses) as request:
|
|
||||||
result = evaluate(loaded, "http://127.0.0.1:9200", "test", 10)
|
result = evaluate(loaded, "http://127.0.0.1:9200", "test", 10)
|
||||||
|
|
||||||
self.assertEqual(result["summary"], {"query_count": 2, "recall_at_10": 1.0, "mrr_at_10": 0.75})
|
self.assertEqual(result["summary"], {"query_count": 2, "recall_at_10": 1.0, "mrr_at_10": 0.75})
|
||||||
self.assertEqual(result["queries"][0]["reciprocal_rank_at_10"], 1.0)
|
self.assertEqual(result["queries"][0]["reciprocal_rank_at_10"], 1.0)
|
||||||
body = json.loads(request.call_args_list[0].args[2])
|
body = json.loads(build_search_body("ru", "трудовой договор", 10))
|
||||||
self.assertFalse(body["track_total_hits"])
|
self.assertFalse(body["track_total_hits"])
|
||||||
self.assertEqual(body["collapse"], {"field": "document_code"})
|
self.assertEqual(body["collapse"], {"field": "document_code"})
|
||||||
|
self.assertEqual(body["query"]["bool"]["must"]["multi_match"]["type"], "cross_fields")
|
||||||
|
|
||||||
|
def test_company_registration_intent_boosts_current_documents(self):
|
||||||
|
for language, query, status in (
|
||||||
|
("ru", "как открыть ОсОО", "Действует"),
|
||||||
|
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
|
||||||
|
):
|
||||||
|
body = json.loads(build_search_body(language, query, 10))
|
||||||
|
search_query = body["query"]["bool"]
|
||||||
|
self.assertEqual(search_query["minimum_should_match"], 1)
|
||||||
|
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
|
||||||
|
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
|
||||||
|
self.assertEqual(
|
||||||
|
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
|
||||||
|
["230044970", "667"],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
|
||||||
|
|
||||||
|
def test_company_registration_intent_ignores_non_procedural_queries(self):
|
||||||
|
for language, query in (
|
||||||
|
("ru", "ОсОО зарегистрирован?"),
|
||||||
|
("ru", "кто зарегистрировал ОсОО"),
|
||||||
|
("ru", "как открыть счет ОсОО"),
|
||||||
|
("ru", "как открыть филиал ОсОО"),
|
||||||
|
("ru", "как создать договор для ОсОО"),
|
||||||
|
("ru", "порядок создания логотипа ОсОО"),
|
||||||
|
("ky", "ЖЧК ачык маалымат"),
|
||||||
|
("ky", "ЖЧК кантип банк эсебин ачуу"),
|
||||||
|
("ky", "ЖЧК кантип келишим түзүү"),
|
||||||
|
("ky", "ЖЧК кантип логотип түзүү"),
|
||||||
|
):
|
||||||
|
body = json.loads(build_search_body(language, query, 10))
|
||||||
|
self.assertNotIn("should", body["query"]["bool"])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
правила работы с данными.
|
правила работы с данными.
|
||||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||||
MVP, зависимости и спринты.
|
MVP, зависимости и спринты.
|
||||||
|
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||||
|
обязательные работы и критерии перехода к frontend.
|
||||||
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
||||||
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
||||||
|
|
||||||
@@ -33,4 +35,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
- Telegram-бот: `0.2.2`
|
- Telegram-бот: `0.2.2`
|
||||||
- Telegram-бот на Synology: `0.2.1`
|
- Telegram-бот на Synology: `0.2.1`
|
||||||
- Backend: `0.5.0`
|
- Backend: `0.6.0`
|
||||||
- Frontend: не создан
|
- Frontend: не создан
|
||||||
|
|
||||||
## Краткий итог
|
## Краткий итог
|
||||||
@@ -135,6 +135,14 @@
|
|||||||
|
|
||||||
## История изменений статуса
|
## История изменений статуса
|
||||||
|
|
||||||
|
### 2026-08-18
|
||||||
|
|
||||||
|
- Оценщик поиска использует `cross_fields` для совместного сопоставления
|
||||||
|
названия и текста документа.
|
||||||
|
- На размеченном наборе из 50 запросов Recall@10 вырос с `0.23` до `0.30`,
|
||||||
|
MRR@10 — с `0.1854` до `0.2272`.
|
||||||
|
- Версия backend обновлена до `0.5.1`.
|
||||||
|
|
||||||
### 2026-08-14
|
### 2026-08-14
|
||||||
|
|
||||||
- Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K.
|
- Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K.
|
||||||
@@ -226,4 +234,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
126
docs/product/frontend-design-readiness-plan.md
Normal file
126
docs/product/frontend-design-readiness-plan.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Готовность к проектированию frontend
|
||||||
|
|
||||||
|
Этот документ определяет обязательный результат до начала проектирования и
|
||||||
|
разработки пользовательского интерфейса. Он дополняет
|
||||||
|
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
|
||||||
|
спринты frontend, этот — критерий перехода к ним.
|
||||||
|
|
||||||
|
## Правило перехода
|
||||||
|
|
||||||
|
Проектирование frontend начинается, когда выполнены все обязательные пункты
|
||||||
|
этого плана и пройден финальный readiness gate. До этого не создаются
|
||||||
|
`frontend/`, макеты, UI-компоненты или mock-данные, заменяющие неготовый
|
||||||
|
backend-контракт.
|
||||||
|
|
||||||
|
Вне этого этапа остаются аккаунты, уведомления, RAG, судебная практика и
|
||||||
|
внешние коммерческие сервисы.
|
||||||
|
|
||||||
|
## 1. Данные и поисковый индекс
|
||||||
|
|
||||||
|
### Результат
|
||||||
|
|
||||||
|
В локальном OpenSearch доступен воспроизводимо собранный корпус актуальных
|
||||||
|
редакций ЦБД Минюста КР, пригодный для поиска на русском и кыргызском языках.
|
||||||
|
|
||||||
|
### Обязательные работы
|
||||||
|
|
||||||
|
1. Подтвердить для каждого индексируемого документа наличие канонических
|
||||||
|
реквизитов: код, редакция, язык, статус, тип, орган, дата, номер, название
|
||||||
|
и ссылка на официальный источник.
|
||||||
|
2. Зафиксировать правила для документов без текста и одноязычных редакций:
|
||||||
|
они не скрываются и не получают выдуманный перевод.
|
||||||
|
3. Проверить versioned-индекс, mapping, анализаторы RU/KY, полноту актуальных
|
||||||
|
редакций и процедуру безопасной переиндексации с переключением alias.
|
||||||
|
4. Описать и выполнить воспроизводимый сценарий обновления:
|
||||||
|
выгрузка → нормализация → новый индекс → проверка → переключение alias.
|
||||||
|
|
||||||
|
### Критерий приёмки
|
||||||
|
|
||||||
|
Команда может повторить обновление на чистом локальном окружении, проверить
|
||||||
|
количество документов и фрагментов, а затем безопасно переключить поисковый
|
||||||
|
alias без смешивания старого и нового корпуса.
|
||||||
|
|
||||||
|
## 2. Relevance set и качество поиска
|
||||||
|
|
||||||
|
### Результат
|
||||||
|
|
||||||
|
Есть замороженный набор `relevance set v1` из 50 практических запросов:
|
||||||
|
минимум 25 на русском и 25 на кыргызском языках.
|
||||||
|
|
||||||
|
### Обязательные работы
|
||||||
|
|
||||||
|
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
|
||||||
|
естественными формулировками пользователей и подтверждёнными
|
||||||
|
`document_code` релевантных действующих актов.
|
||||||
|
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
|
||||||
|
включать запросы без установленного эталонного результата.
|
||||||
|
3. Провести независимую вторую проверку языка, формулировки и полного списка
|
||||||
|
кодов. Спорные результаты не включать до согласования.
|
||||||
|
4. После проверки сохранить неизменяемую копию набора и baseline
|
||||||
|
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
|
||||||
|
с этим baseline.
|
||||||
|
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
|
||||||
|
практическом действии. Правило принимается, только если улучшает
|
||||||
|
подтверждённые запросы и не создаёт ложных срабатываний в негативных
|
||||||
|
сценариях.
|
||||||
|
|
||||||
|
### Критерий приёмки
|
||||||
|
|
||||||
|
Оценщик проходит на полном наборе, выводит метрики и выдачу для каждого
|
||||||
|
запроса; baseline и результаты его повторного запуска воспроизводимы.
|
||||||
|
|
||||||
|
## 3. Справочники и контракт API
|
||||||
|
|
||||||
|
### Результат
|
||||||
|
|
||||||
|
Frontend получает все юридически значимые данные через версионированный
|
||||||
|
OpenAPI-контракт, а не реконструирует их из текста фрагментов.
|
||||||
|
|
||||||
|
### Обязательные работы
|
||||||
|
|
||||||
|
1. Утвердить справочники v1: типы документов, органы принятия, статусы,
|
||||||
|
уровни действия и темы. Для каждого значения определить стабильный код и
|
||||||
|
подписи RU/KY.
|
||||||
|
2. Реализовать и описать OpenAPI для:
|
||||||
|
- `GET /search` — запрос, язык, фильтры, сортировка, серверная пагинация,
|
||||||
|
выдержка и подсветка;
|
||||||
|
- `GET /search/filters` — допустимые значения фильтров;
|
||||||
|
- `GET /documents/{code}` — карточка актуального документа;
|
||||||
|
- `GET /documents/{code}/editions` и
|
||||||
|
`GET /documents/{code}/editions/{edition}` — редакции и их содержимое.
|
||||||
|
3. Зафиксировать единые ответы для пустой выдачи, неизвестного документа,
|
||||||
|
недоступной редакции и ошибки upstream; для документов с одним языком
|
||||||
|
вернуть доступные языки явно.
|
||||||
|
4. Добавить контрактные и интеграционные проверки API на локальном OpenSearch:
|
||||||
|
поиск, фильтры, пагинация, сортировка, документ, редакции и одноязычные
|
||||||
|
акты.
|
||||||
|
|
||||||
|
### Критерий приёмки
|
||||||
|
|
||||||
|
OpenAPI опубликован вместе с backend, тестовый клиент получает реальные данные
|
||||||
|
по всем endpoint без mock-слоя, а результаты поиска открывают актуальный
|
||||||
|
документ и выбранную редакцию.
|
||||||
|
|
||||||
|
## 4. Финальный readiness gate
|
||||||
|
|
||||||
|
Перед началом frontend выполнить и зафиксировать один сквозной сценарий:
|
||||||
|
|
||||||
|
1. Обновить корпус из официальной ЦБД.
|
||||||
|
2. Нормализовать данные и собрать новый versioned-индекс.
|
||||||
|
3. Прогнать проверки индекса и relevance baseline.
|
||||||
|
4. Переключить alias на проверенный индекс.
|
||||||
|
5. Выполнить API-сценарии поиска, фильтрации, просмотра документа и редакции
|
||||||
|
на RU, KY и одноязычном документе.
|
||||||
|
|
||||||
|
Gate считается пройденным, если все проверки успешны, зафиксированы версии
|
||||||
|
backend и индекса, опубликованы известные ограничения и назначен ответственный
|
||||||
|
за юридическую проверку relevance set.
|
||||||
|
|
||||||
|
## Порядок issues
|
||||||
|
|
||||||
|
1. Собрать и независимо проверить relevance set v1.
|
||||||
|
2. Завершить проверку полноты данных и воспроизводимую переиндексацию с alias.
|
||||||
|
3. Утвердить справочники v1.
|
||||||
|
4. Реализовать OpenAPI и интеграционные проверки.
|
||||||
|
5. Провести финальный readiness gate и только затем открыть задачу на
|
||||||
|
проектирование frontend.
|
||||||
@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -44,4 +44,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -180,4 +180,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ python3 -m unittest -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.6.0 · Frontend — не создан
|
||||||
|
|||||||
Reference in New Issue
Block a user