fix: rank repealed search results last #36

Merged
admin merged 1 commits from fix/search-status-order-and-empty-snippet into main 2026-09-16 11:27:01 +00:00
9 changed files with 37 additions and 17 deletions

View File

@@ -2,6 +2,8 @@
## Не выпущено ## Не выпущено
- Backend обновлён до `0.10.2`: действующие документы ранжируются выше, а
утратившие силу — после остальных; из лаборатории удалён пустой блок фрагмента.
- Backend обновлён до `0.10.1`: поисковая выдача по регистрации больше не - Backend обновлён до `0.10.1`: поисковая выдача по регистрации больше не
ограничивается только тремя заранее выбранными нормативными актами. ограничивается только тремя заранее выбранными нормативными актами.
- Backend обновлён до `0.10.0`: поисковый API использует общий планировщик - Backend обновлён до `0.10.0`: поисковый API использует общий планировщик

View File

@@ -10,14 +10,14 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние ## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.10.1`. Поиск расширяет аббревиатуры организационно-правовых форм и поднимает `0.10.2`. Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
положение о регистрации 2026 года, закон об обществах и ГК КР для запросов об положение о регистрации 2026 года, закон об обществах и ГК КР для запросов об
открытии ОсОО/ЖЧК. открытии ОсОО/ЖЧК.
| Компонент | Версия | Состояние | | Компонент | Версия | Состояние |
|---|---:|---| |---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.10.1` | расширение терминов и приоритет действующих актов о регистрации ОсОО/ЖЧК | | Backend | `0.10.2` | расширение терминов и приоритет действующих актов о регистрации ОсОО/ЖЧК |
| Frontend | — | ещё не создан | | Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи | | Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы | | RAG и база знаний | — | ещё не созданы |
@@ -60,4 +60,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.2 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.10.1` Версия: `0.10.2`
Первая backend-область проекта — загрузка правовых документов из официального Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -240,4 +240,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
--- ---
Акылдаш · Backend v0.10.1 · Frontend — не создан Акылдаш · Backend v0.10.2 · Frontend — не создан

View File

@@ -17,7 +17,7 @@ from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1" API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-3" SEARCH_ALGORITHM_VERSION = "search-4"
LANGUAGES = {"ru", "ky"} LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$") CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100 MAX_PAGE_SIZE = 100
@@ -144,8 +144,14 @@ class Api:
"collapse": {"field": "document_code"}, "collapse": {"field": "document_code"},
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}}, "highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
} }
body["sort"] = [{"_script": {
"type": "number",
"order": "asc",
"script": {"lang": "painless", "source": "doc['status_code'].size() == 0 ? 1 : (doc['status_code'].value == 'active' ? 0 : (doc['status_code'].value == 'repealed' ? 2 : 1))"},
}}]
if sort == "date": if sort == "date":
body["sort"] = [{"date_adopted": "desc"}, {"_score": "desc"}] body["sort"].append({"date_adopted": "desc"})
body["sort"].append({"_score": "desc"})
response = self.query_opensearch(body) response = self.query_opensearch(body)
try: try:
hits = response["hits"]["hits"] hits = response["hits"]["hits"]

View File

@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.10.1" APP_VERSION = "0.10.2"
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")

View File

@@ -46,9 +46,7 @@
.review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); } .review__result-title { display: flex; gap: .65rem; align-items: baseline; width: 100%; text-align: start; font-weight: 800; border: 0; padding: 0; background: none; color: var(--brand-dark); }
.review__result-title:hover { background: none; border-color: transparent; color: var(--brand); } .review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
.review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; } .review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
.review__meta, .review__snippet { margin-block: .5rem; color: var(--muted); } .review__meta { margin-block: .5rem; color: var(--muted); font-size: .8rem; }
.review__meta { font-size: .8rem; }
.review__snippet { overflow-wrap: anywhere; }
.review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; } .review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
.review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; } .review__rating legend { width: 100%; margin-block-end: .35rem; font-size: .8rem; font-weight: 700; color: var(--muted); grid-column: 1 / -1; }
.review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; } .review__rating label { min-width: 0; padding: .45rem .25rem; border: 1px solid #c5d0d8; border-radius: .5rem; color: var(--muted); text-align: center; font-size: .8rem; cursor: pointer; }
@@ -119,7 +117,7 @@
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div> <div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div>
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog> <dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog> <div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header">Акылдаш · Backend v0.10.1 · Внутренняя лаборатория релевантности</footer> <footer class="review__header">Акылдаш · Backend v0.10.2 · Внутренняя лаборатория релевантности</footer>
<script> <script>
const DRAFT_KEY = 'akyldash-search-review-draft'; const DRAFT_KEY = 'akyldash-search-review-draft';
const TOUR_KEY = 'akyldash-search-review-tour-seen'; const TOUR_KEY = 'akyldash-search-review-tour-seen';
@@ -146,11 +144,10 @@
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code; const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title)); const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · '); const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`; const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value}${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); }); [['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value}${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`); const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
item.append(title, meta, snippet, rating, comment); $('results').append(item); item.append(title, meta, rating, comment); $('results').append(item);
}); });
applyDraft(); applyDraft();
} }

View File

@@ -49,6 +49,21 @@ class SearchApiTest(unittest.TestCase):
self.assertTrue(any("constant_score" in clause for clause in planned["should"])) self.assertTrue(any("constant_score" in clause for clause in planned["should"]))
self.assertTrue(any("общество с ограниченной ответственностью" in clause.get("multi_match", {}).get("query", "") for clause in planned["should"])) self.assertTrue(any("общество с ограниченной ответственностью" in clause.get("multi_match", {}).get("query", "") for clause in planned["should"]))
def test_search_sorts_repealed_documents_after_other_statuses(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
with patch("search.api.request_json", return_value={"hits": {"hits": []}}) as request:
api.handle("GET", "/search?q=НДС+на+импорт&language=ru")
body = json.loads(request.call_args.args[2])
source = body["sort"][0]["_script"]["script"]["source"]
self.assertIn("'active' ? 0", source)
self.assertIn("'repealed' ? 2", source)
self.assertEqual(body["sort"][1], {"_score": "desc"})
api.handle("GET", "/search?q=НДС+на+импорт&language=ru&sort=date")
body = json.loads(request.call_args.args[2])
self.assertEqual(body["sort"][1], {"date_adopted": "desc"})
def test_document_editions_openapi_and_validation(self): def test_document_editions_openapi_and_validation(self):
with tempfile.TemporaryDirectory() as temporary: with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary)) api = self.make_api(Path(temporary))

View File

@@ -39,4 +39,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.2 · Frontend — не создан

View File

@@ -5,7 +5,7 @@
- Telegram-бот: `0.2.2` - Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1` - Telegram-бот на Synology: `0.2.1`
- Backend: `0.10.1` - Backend: `0.10.2`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
@@ -262,4 +262,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.2 · Frontend — не создан