Fix search lease terminology and empty reviews
This commit is contained in:
1 parent
7ece39f212
commit
aa6fabe970
10 files changed
+243
-21
No files matched your search
@@ -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
|
||||
+5
-1
@@ -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`
|
||||
нужно резервировать отдельно от поискового индекса. Экспорт доступен
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
+19
-1
@@ -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"(?<!\w)(договор(?:а|у|ом|е)?)[ \t]+аренд(?:а|ы|е|у|ой|ою)(?!\w)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
LEASE_TERM_ALIAS_EXCLUSIONS = re.compile(
|
||||
r"\b(?:квартир\w*|жил\w*|дом\w*|помещен\w*|земл\w*|участк\w*|"
|
||||
r"подна[её]м\w*|субаренд\w*|лизинг\w*|труд\w*|работник\w*)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
|
||||
TAX_PROFIT_DOCUMENTS = (("112340", 3000),)
|
||||
BOILERPLATE_PREFIXES = {
|
||||
@@ -68,6 +80,12 @@ def query_variants(language: str, query: str) -> list[str]:
|
||||
pattern = re.compile(rf"(?<!\w){re.escape(alias)}(?!\w)", re.IGNORECASE)
|
||||
if pattern.search(query):
|
||||
variants.extend(pattern.sub(expansion, query) for expansion in expansions)
|
||||
if (
|
||||
language == "ru"
|
||||
and LEASE_TERM_ALIAS.search(query)
|
||||
and not LEASE_TERM_ALIAS_EXCLUSIONS.search(query)
|
||||
):
|
||||
variants.append(LEASE_TERM_ALIAS.sub(r"\1 имущественного найма", query))
|
||||
return list(dict.fromkeys(variants))
|
||||
|
||||
|
||||
@@ -208,7 +226,7 @@ def main() -> 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
|
||||
|
||||
|
||||
|
||||
@@ -657,7 +657,7 @@
|
||||
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.3</span></footer>
|
||||
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.4 <span aria-hidden="true">·</span> Backend v0.13.4</span></footer>
|
||||
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
|
||||
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
|
||||
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
|
||||
@@ -675,7 +675,7 @@
|
||||
<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 class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></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"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.3 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.4 · Backend v0.13.4 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -709,8 +709,8 @@
|
||||
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
|
||||
document.body.classList.toggle('is-review-route', reviewRoute);
|
||||
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.3'; }
|
||||
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
|
||||
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.4 · Backend v0.13.4'; }
|
||||
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
|
||||
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
|
||||
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = roleLabel(user); $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
|
||||
@@ -1343,7 +1343,7 @@
|
||||
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
|
||||
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
|
||||
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.3`;
|
||||
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.4 · Backend v0.13.4`;
|
||||
if (usersRoute && account) loadUsers();
|
||||
};
|
||||
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
|
||||
@@ -1500,9 +1500,9 @@
|
||||
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); }
|
||||
window.addEventListener('resize', () => { if ($('tutorial-dialog').open) renderTutorial(); });
|
||||
$('tutorial-open').addEventListener('click', openTutorial); $('tutorial-next').addEventListener('click', () => { if (tutorialStep === TOUR_STEPS.length - 1) closeTutorial(); else { tutorialStep += 1; renderTutorial(); } }); $('tutorial-skip').addEventListener('click', closeTutorial); $('tutorial-dialog').addEventListener('cancel', (event) => { event.preventDefault(); closeTutorial(); });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token && state.results.length ? 'Сохранить оценку' : 'Нет данных для сохранения'; $('save').disabled = !state.token || !state.results.length; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу сохранить нельзя.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; const requestId = ++state.searchRequest; state.documentRequest += 1; state.query = query; state.token = ''; setStatus('Поиск выполняется…'); $('save').disabled = true; $('save').textContent = 'Поиск выполняется…'; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await apiFetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); if (requestId !== state.searchRequest) return; state.results = payload.results; state.token = payload.review_token; renderResults(); $('overall-comment').setAttribute('aria-required', String(!state.results.length)); $('overall-comment').placeholder = state.results.length ? 'Что важно учесть? (необязательно)' : 'Объясните, почему выдача пуста (обязательно)'; setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); $('save').textContent = state.token ? (state.results.length ? 'Сохранить оценку' : 'Сохранить пустую выдачу') : 'Нет данных для сохранения'; $('save').disabled = !state.token; $('draft-status').textContent = state.results.length ? 'Оценка ещё не сохранена.' : 'Пустую выдачу можно сохранить с комментарием.'; } catch (error) { if (requestId !== state.searchRequest) return; state.results = []; state.token = ''; renderResults(); $('overall-comment').setAttribute('aria-required', 'false'); $('overall-comment').placeholder = 'Что важно учесть? (необязательно)'; setError('Не удалось выполнить поиск. Повторите поиск.'); $('save').textContent = 'Сначала выполните поиск'; $('save').disabled = true; $('draft-status').textContent = 'Поиск не завершился; черновик не создан.'; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } if (!state.results.length && !$('overall-comment').value.trim()) { setError('Добавьте комментарий к пустой выдаче.'); $('overall-comment').focus(); return; } const reviewer = $('reviewer').value.trim(); const results = state.results.map((result, index) => { const item = $('results').children[index]; return {rank: index + 1, code: result.code, rating: item?.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item?.querySelector('textarea')?.value || ''}; }); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await apiFetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(draftStorageKey()); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = account?.username || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
if (reviewRoute) $('tutorial-open').hidden = false;
|
||||
</script>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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, диагностические логи, внешние пользовательские данные или выгрузка
|
||||
лабораторных комментариев.
|
||||
Reference in new issue
Block a user