Compare commits

..

14 Commits

Author SHA1 Message Date
c2537cf445 fix: rank repealed search results last 2026-09-16 14:07:28 +03:00
dffe072ce2 Merge pull request 'feat: improve legal search intent and aliases' (#35) from feature/improve-legal-search-relevance into main
Reviewed-on: #35
2026-09-16 09:08:37 +00:00
06c275cc40 fix: keep registration search results open 2026-09-16 10:07:39 +03:00
92051184f5 fix: avoid registration routing for bank accounts 2026-09-16 10:00:33 +03:00
c048f20734 fix: prioritize current company registration act 2026-09-16 09:56:11 +03:00
af6b0b8098 feat: improve legal search intent and aliases 2026-09-16 09:48:46 +03:00
94ba04ff56 Merge pull request 'fix: hide search highlight markup in snippets' (#34) from fix/plain-search-snippets into main
Reviewed-on: #34
2026-09-16 06:22:34 +00:00
9fb266e8ac fix: hide search highlight markup in snippets 2026-09-16 09:16:43 +03:00
f045233a50 Merge pull request 'feat: polish search review interface' (#33) from feature/polish-review-interface into main
Reviewed-on: #33
2026-09-16 06:11:48 +00:00
6a187760db fix: restore local search review flow 2026-09-16 06:54:37 +03:00
90298fa6cc fix: keep tutorial spotlight visible on mobile 2026-09-16 00:26:48 +03:00
503cb1f074 fix: address review findings 2026-09-16 00:20:43 +03:00
4d276e0776 feat: polish search review interface 2026-09-16 00:13:48 +03:00
298191173e Merge pull request 'Сохранять документы с неполными значениями справочников' (#32) from fix/unmapped-search-catalog-values into main
Reviewed-on: #32
2026-09-15 21:00:24 +00:00
14 changed files with 270 additions and 93 deletions

View File

@@ -2,6 +2,20 @@
## Не выпущено ## Не выпущено
- Backend обновлён до `0.10.2`: действующие документы ранжируются выше, а
утратившие силу — после остальных; из лаборатории удалён пустой блок фрагмента.
- Backend обновлён до `0.10.1`: поисковая выдача по регистрации больше не
ограничивается только тремя заранее выбранными нормативными актами.
- Backend обновлён до `0.10.0`: поисковый API использует общий планировщик
запросов, расширяет аббревиатуры ОсОО/ООО/ЖЧК и для запроса о регистрации
приоритизирует положение КР 2026 года, закон об обществах и ГК КР.
- Backend обновлён до `0.9.4`: из поисковых фрагментов убраны технические маркеры
подсветки OpenSearch.
- Backend обновлён до `0.9.3`: исправлен локальный индекс production Search API,
ошибки поиска показываются в общем status-блоке, а панель сохранения и footer
выстроены в обычном порядке документа.
- Backend обновлён до `0.9.2`: внутренняя лаборатория получила визуальный
polish-проход и короткий повторно запускаемый tutorial для новых проверяющих.
- Backend обновлён до `0.9.1`: все статусы из нормализованного корпуса доступны - Backend обновлён до `0.9.1`: все статусы из нормализованного корпуса доступны
как отдельные фильтры, варианты конституционного закона и указа сопоставляются как отдельные фильтры, варианты конституционного закона и указа сопоставляются
с подтверждённой юридической силой, неизвестные типы документов сохраняются с подтверждённой юридической силой, неизвестные типы документов сохраняются

View File

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

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.9.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.9.1 · Frontend — не создан Акылдаш · Backend v0.10.2 · Frontend — не создан

View File

@@ -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.9.1" APP_VERSION = "0.9.3"
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"}

View File

@@ -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.9.1" APP_VERSION = "0.9.3"
SCHEMA_VERSION = "1" SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0" NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky") LANGUAGES = ("ru", "ky")

View File

@@ -12,11 +12,12 @@ from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels from search.catalog import CATALOGS, labels
from search.query import build_search_query
from search.reviews import ReviewSnapshots, ReviewStore from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1" API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-1" 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
@@ -139,12 +140,18 @@ class Api:
"from": (page - 1) * page_size, "from": (page - 1) * page_size,
"size": page_size + 1, "size": page_size + 1,
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"], "_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
"query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}}, "query": {"bool": {"filter": filters, "must": build_search_query(language, text)}},
"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"]
@@ -163,7 +170,8 @@ class Api:
raise ApiError(502, "search backend returned an incomplete result") raise ApiError(502, "search backend returned an incomplete result")
highlight = hit.get("highlight", {}).get(f"text_{language}", []) highlight = hit.get("highlight", {}).get(f"text_{language}", [])
legal_force = source.get("legal_force_code") legal_force = source.get("legal_force_code")
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": highlight[0] if highlight else None} snippet = highlight[0].replace("<em>", "").replace("</em>", "") if highlight else None
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": snippet}
def filters(self, query: dict[str, list[str]]) -> dict: def filters(self, query: dict[str, list[str]]) -> dict:
language = one(query, "language") or "ru" language = one(query, "language") or "ru"

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.9.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

@@ -10,12 +10,60 @@ import urllib.parse
from search.minjust_opensearch import APP_VERSION, request_json from search.minjust_opensearch import APP_VERSION, request_json
LEGAL_FORM_ALIASES = {
"ru": {
"осоо": ("ооо", "общество с ограниченной ответственностью"),
"ооо": ("осоо", "общество с ограниченной ответственностью"),
"общество с ограниченной ответственностью": ("осоо", "ооо"),
"общества с ограниченной ответственностью": ("осоо", "ооо"),
},
"ky": {
"жчк": ("жоопкерчилиги чектелген коом",),
"жоопкерчилиги чектелген коом": ("жчк",),
},
}
COMPANY_REGISTRATION_DOCUMENTS = (("230044970", 3000), ("667", 2000), ("4", 1000))
def query_variants(language: str, query: str) -> list[str]:
variants = [query]
for alias, expansions in LEGAL_FORM_ALIASES[language].items():
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)
return list(dict.fromkeys(variants))
def company_registration_clauses(language: str, query: str) -> list[dict]: def company_registration_clauses(language: str, query: str) -> list[dict]:
patterns = { non_registration_context = {
"ru": (r"\bкак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\bкак\s+зарегистр\w*\s+осоо\b"), "ru": (r"\bсч[её]т\w*", r"\bбанк\w*", r"\bфилиал\w*", r"\bпредставительств\w*"),
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"), "ky": (r"\bбанк\w*", r"\сеп\w*", r"\bфилиал\w*", r"\bөкүлчүлүк\w*"),
}[language] }[language]
if not any(re.search(pattern, query.casefold()) for pattern in patterns): normalized_query = query.casefold()
if any(re.search(pattern, normalized_query) for pattern in non_registration_context):
return []
forms, patterns = {
"ru": (
r"(?:осоо|ооо|обществ\w*\s+с\s+ограниченной\s+ответственностью)",
(
r"\ак\s+(?:откры\w*|созда\w*|зарегистр\w*)\s+{form}\b",
r"\b(?:порядок|процедура)\s+(?:открыти\w*|создани\w*|регистраци\w*)\s+{form}\b",
r"\b(?:государственн\w+\s+)?регистраци\w*\s+{form}\b",
r"\bсоздани\w*\s+{form}\b",
),
),
"ky": (
r"(?:жчк|жоопкерчилиги\s+чектелген\s+коом)",
(
r"\bжчк\s+ач\w*\s+тартиби\b",
r"\bжчк\s+кантип\s+ач\w*\b",
r"\b{form}\s+(?:ачуу|ачуунун|каттоо|каттоонун)\b",
r"\b(?:ачуу|каттоо)\s+{form}\b",
),
),
}[language]
if not any(re.search(pattern.format(form=forms), normalized_query) for pattern in patterns):
return [] return []
status = {"ru": "Действует", "ky": "Күчүндө"}[language] status = {"ru": "Действует", "ky": "Күчүндө"}[language]
@@ -35,28 +83,32 @@ def company_registration_clauses(language: str, query: str) -> list[dict]:
} }
} }
return [clause("230044970", 2000), clause("667", 1000)] return [clause(code, boost) for code, boost in COMPANY_REGISTRATION_DOCUMENTS]
def build_search_query(language: str, query: str) -> dict:
intent_clauses = company_registration_clauses(language, query)
full_text = [
{
"multi_match": {
"query": variant,
"fields": [f"document_name_{language}^4", f"text_{language}"],
"type": "cross_fields",
"operator": "OR" if intent_clauses else "AND",
}
}
for variant in query_variants(language, query)
]
planned = {"should": [*full_text, *intent_clauses], "minimum_should_match": 1}
return {"bool": planned}
def build_search_body(language: str, query: str, top_k: int) -> bytes: 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({ return json.dumps({
"size": top_k, "size": top_k,
"track_total_hits": False, "track_total_hits": False,
"_source": ["document_code"], "_source": ["document_code"],
"query": {"bool": bool_query}, "query": {"bool": {"filter": [{"term": {"language": language}}, {"term": {"is_current_edition": True}}], "must": build_search_query(language, query)}},
"collapse": {"field": "document_code"}, "collapse": {"field": "document_code"},
}, ensure_ascii=False).encode() }, ensure_ascii=False).encode()

View File

@@ -5,110 +5,168 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Оценка поисковой выдачи · Акылдаш</title> <title>Оценка поисковой выдачи · Акылдаш</title>
<style> <style>
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; } :root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #eef2f5; --ink: #17202a; --muted: #5d6b78; --line: #dbe2e8; --surface: #fff; --surface-muted: #f7f9fb; --brand: #0f5b78; --brand-dark: #0a4258; --accent: #e7b84b; --focus: #1769aa; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; } body { margin: 0; color: var(--ink); background: radial-gradient(circle at 85% -10%, #dcecf0 0, transparent 35rem), #eef2f5; }
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: #fff; } .review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: var(--surface); }
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; } .review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; } .review__header, .review__main { max-width: 1320px; margin: auto; padding-inline: clamp(1rem, 3vw, 3rem); }
.review__header { padding-block: 1.5rem 1rem; } .review__header { padding-block: clamp(2rem, 5vw, 4rem) 1.5rem; }
.review__main { padding-block-end: 8rem; } .review__header h1 { max-width: 18ch; margin: .35rem 0 .75rem; font-size: clamp(2rem, 4vw, 3.5rem); line-height: 1.05; letter-spacing: -.04em; }
.review__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; } .review__header p { max-width: 58ch; margin: 0; color: var(--muted); font-size: 1.05rem; }
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; } .review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.review__field--small { flex: 0 0 7rem; min-width: 7rem; } .review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.review__eyebrow::before { content: ''; width: .55rem; height: .55rem; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 4px rgb(231 184 75 / .2); }
.review__main { padding-block-end: 2rem; }
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 1.25rem 3rem rgb(24 42 54 / .08); }
.review__field { display: grid; gap: .35rem; min-width: 0; }
.review__field span, .review__actions label { color: var(--muted); font-size: .8rem; font-weight: 700; }
.review__field--small { min-width: 0; }
input, select, textarea, button { font: inherit; } input, select, textarea, button { font: inherit; }
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; } input, select, textarea { width: 100%; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem .8rem; background: var(--surface); color: var(--ink); }
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; } input::placeholder, textarea::placeholder { color: #87939e; }
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; } input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
button:hover { background: #edf3f8; } button { cursor: pointer; border: 1px solid #b8c4ce; border-radius: .6rem; padding: .7rem 1rem; background: var(--surface); color: var(--ink); font-weight: 700; transition-property: background-color, border-color, color, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; } button:hover { background: #edf4f7; border-color: #7d9aaa; }
.review__button--primary:hover { background: #0e4669; } button:active { scale: .96; }
.review__status { min-height: 1.7rem; margin-block: .75rem; } button:disabled { cursor: wait; opacity: .55; }
.review__button--primary { background: var(--brand); color: #fff; border-color: var(--brand); }
.review__button--primary:hover { background: var(--brand-dark); border-color: var(--brand-dark); }
.review__status { min-height: 1.7rem; margin-block: 1rem; color: var(--muted); }
.review__status--error { color: #9b1c1c; } .review__status--error { color: #9b1c1c; }
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; } .review__workspace { display: grid; grid-template-columns: minmax(24rem, .9fr) minmax(28rem, 1.1fr); gap: 1.25rem; align-items: start; }
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; } .review__results, .review__document { min-width: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; padding: clamp(1rem, 2vw, 1.5rem); box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
.review__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; } .review__panel-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: baseline; margin-block-end: 1rem; }
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; } .review__panel-heading h2 { margin: 0; font-size: 1.1rem; letter-spacing: -.01em; }
.review__result:first-child { border-block-start: 0; padding-block-start: 0; } .review__panel-hint { color: var(--muted); font-size: .8rem; }
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; } .review__results-list { display: grid; gap: .75rem; margin: 0; padding: 0; list-style: none; }
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; } .review__result { padding: 1rem; border: 1px solid var(--line); border-radius: .75rem; background: var(--surface-muted); transition-property: background-color, border-color, box-shadow, scale; transition-duration: 150ms; transition-timing-function: cubic-bezier(.2, 0, 0, 1); }
.review__meta, .review__snippet { margin-block: .35rem; color: #526272; } .review__result:hover, .review__result--selected { border-color: #8eb3c1; background: #f1f8fa; box-shadow: 0 .5rem 1rem rgb(15 91 120 / .08); }
.review__snippet { overflow-wrap: anywhere; } .review__result:first-child { border-block-start: 1px solid var(--line); }
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; } .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__rating legend { width: 100%; font-weight: 600; } .review__result-title:hover { background: none; border-color: transparent; color: var(--brand); }
.review__rating label { min-width: 3.5rem; text-align: center; } .review__rank { flex: 0 0 auto; color: var(--brand); font-variant-numeric: tabular-nums; }
.review__rating input { accent-color: #145a86; } .review__meta { margin-block: .5rem; color: var(--muted); font-size: .8rem; }
.review__comment { width: 100%; min-height: 4rem; resize: vertical; } .review__rating { display: grid; grid-template-columns: repeat(4, 1fr); gap: .4rem; margin-block: 1rem .75rem; padding: 0; border: 0; }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; } .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:has(input:checked) { border-color: var(--brand); background: var(--brand); color: #fff; }
.review__rating label:hover { border-color: #7d9aaa; }
.review__rating label:has(input:focus-visible) { outline: 3px solid var(--focus); outline-offset: 2px; }
.review__rating input { position: absolute; opacity: 0; inline-size: 1px; block-size: 1px; }
.review__comment { min-height: 4rem; resize: vertical; background: var(--surface); }
.review__document { position: sticky; inset-block-start: 1rem; min-height: 24rem; }
.review__document-meta { margin-block-end: 1rem; padding-block-end: 1rem; border-block-end: 1px solid var(--line); color: var(--muted); }
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; } .review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
.review__document-body img { max-width: 100%; height: auto; } .review__document-body img { max-width: 100%; height: auto; }
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; } .review__actions { max-width: 1320px; margin: 1.5rem auto 0; padding-inline: clamp(1rem, 3vw, 3rem); }
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; } .review__actions-inner { display: flex; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; box-shadow: 0 .75rem 2rem rgb(24 42 54 / .05); }
.review__actions label { display: grid; gap: .35rem; flex: 1; color: var(--muted); font-size: .8rem; font-weight: 700; }
.review__actions label:last-of-type { flex: 2; }
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid var(--line); border-radius: 1rem; padding: 1.5rem; box-shadow: 0 2rem 5rem rgb(10 20 30 / .2); }
dialog::backdrop { background: rgb(10 20 30 / .5); } dialog::backdrop { background: rgb(10 20 30 / .5); }
.review__dialog-close { float: inline-end; } .review__dialog-close { float: inline-end; }
.review__empty { padding: 3rem 1rem; color: var(--muted); text-align: center; }
.review__empty strong { display: block; margin-block-end: .35rem; color: var(--ink); }
.review__tour-spotlight { position: fixed; z-index: 1; pointer-events: none; border: 4px solid var(--accent); border-radius: .75rem; box-shadow: 0 0 0 100vmax rgb(10 30 40 / .58), 0 0 0 8px rgb(231 184 75 / .25); }
.review__tour { width: min(34rem, calc(100% - 2rem)); padding: 0; color: var(--ink); }
.review__tour::backdrop { background: transparent; }
.review__tour-content { padding: clamp(1.25rem, 4vw, 2rem); }
.review__tour-kicker { margin: 0 0 .5rem; color: var(--brand); font-size: .75rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
.review__tour h2 { margin: 0 0 .65rem; font-size: clamp(1.35rem, 3vw, 1.75rem); line-height: 1.1; letter-spacing: -.02em; }
.review__tour p { margin: 0; color: var(--muted); }
.review__tour-footer { display: flex; justify-content: space-between; gap: .75rem; align-items: center; margin-block-start: 1.5rem; }
.review__tour-progress { color: var(--muted); font-size: .8rem; }
.review__tour-actions { display: flex; gap: .5rem; }
@media (max-width: 800px) { @media (max-width: 800px) {
.review__search { grid-template-columns: 1fr 1fr; }
.review__search .review__field:first-child { grid-column: 1 / -1; }
.review__workspace { grid-template-columns: 1fr; } .review__workspace { grid-template-columns: 1fr; }
.review__main { padding-block-end: 2rem; }
.review__document { display: none; } .review__document { display: none; }
.review__search { align-items: stretch; } .review__document--tour-visible { display: block; }
.review__field, .review__field--small { flex-basis: 100%; } .review__search button { grid-column: 1 / -1; }
.review__search button { width: 100%; } .review__actions { padding-inline: 1rem; }
.review__actions-inner { flex-wrap: wrap; }
.review__actions label { flex: 1 1 14rem !important; }
.review__actions button { flex: 1 1 100%; }
} }
@media (max-width: 480px) { .review__search { grid-template-columns: 1fr; } .review__search .review__field:first-child, .review__search button { grid-column: auto; } .review__rating label { font-size: .72rem; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
</style> </style>
</head> </head>
<body> <body>
<a class="review__skip" href="#results">Перейти к результатам</a> <a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header> <header class="review__header">
<div class="review__header-top"><div class="review__eyebrow">Внутренняя лаборатория</div><button id="tutorial-open" type="button">Как это работает</button></div>
<h1>Проверка качества поиска</h1><p>Помогите проверить, насколько первые результаты отвечают на юридический запрос.</p>
</header>
<main class="review__main" id="review"> <main class="review__main" id="review">
<form class="review__search" id="search-form"> <form class="review__search" id="search-form">
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label> <label class="review__field">Юридический запрос<input id="query" name="q" maxlength="500" required autocomplete="off" placeholder="Например, как открыть ОсОО?"></label>
<label class="review__field review__field--small">Язык<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label> <label class="review__field review__field--small">Язык запроса<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label> <label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
<button class="review__button--primary" type="submit">Найти</button> <button class="review__button--primary" type="submit">Показать результаты</button>
</form> </form>
<div class="review__status" id="status" role="status" aria-live="polite"></div> <div class="review__status" id="status" role="status" aria-live="polite"></div>
<div class="review__workspace"> <div class="review__workspace">
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section> <section class="review__results" aria-labelledby="results-heading"><div class="review__panel-heading"><h2 id="results-heading">Результаты поиска</h2><span class="review__panel-hint">Оцените каждый документ</span></div><ol class="review__results-list" id="results"><li class="review__empty"><strong>Начните с запроса</strong>Введите юридический вопрос выше, чтобы увидеть результаты.</li></ol></section>
<section class="review__document" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></section> <section class="review__document" id="document-panel" aria-labelledby="document-heading"><div class="review__panel-heading"><h2 id="document-heading">Просмотр документа</h2><span class="review__panel-hint">Откройте результат</span></div><div class="review__document-meta" id="document-meta">Выберите название в списке, чтобы прочитать документ.</div><article class="review__document-body" id="document-body"></article></section>
</div> </div>
</main> </main>
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></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__status review__status--error" id="error" role="alert" aria-live="assertive"></div> <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.9.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_STEPS = [
{ target: 'query', title: 'Начните с юридического вопроса', text: 'Введите практический запрос, например «как открыть ОсОО?», выберите язык и нажмите «Показать результаты».', label: 'Шаг 1 из 4' },
{ target: 'results', title: 'Смотрите порядок выдачи', text: 'Документы расположены в том порядке, в котором их нашёл поиск. Номер слева — позиция результата.', label: 'Шаг 2 из 4' },
{ target: 'document-panel', title: 'Читайте документ перед оценкой', text: 'Нажмите на название результата. Справа откроется текст документа и ссылка на официальный источник.', label: 'Шаг 3 из 4' },
{ target: 'save', title: 'Сохраните свою разметку', text: 'Поставьте оценку каждому просмотренному документу, добавьте комментарии и сохраните результат внизу страницы.', label: 'Шаг 4 из 4' }
];
const state = { results: [], token: '', selected: null, query: '' }; const state = { results: [], token: '', selected: null, query: '' };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; }; const setStatus = (text, error = false) => { $('status').textContent = text; $('status').classList.toggle('review__status--error', error); $('status').setAttribute('role', error ? 'alert' : 'status'); $('status').setAttribute('aria-live', error ? 'assertive' : 'polite'); };
const setError = (text) => { $('error').textContent = text; }; const setError = (text) => setStatus(text, true);
const escapeText = (value) => value == null ? '' : String(value); const escapeText = (value) => value == null ? '' : String(value);
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } }; const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
const hasSeenTour = () => { try { return localStorage.getItem(TOUR_KEY) === '1'; } catch (_) { return false; } };
const markTourSeen = () => { try { localStorage.setItem(TOUR_KEY, '1'); } catch (_) {} };
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} }; const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); }; const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
function renderResults() { function renderResults() {
$('results').replaceChildren(); $('results').replaceChildren();
if (!state.results.length) { $('results').innerHTML = '<li class="review__empty"><strong>Результатов пока нет</strong>Введите другой запрос и попробуйте снова.</li>'; return; }
state.results.forEach((result, index) => { state.results.forEach((result, index) => {
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();
} }
async function openDocument(index, trigger) { async function openDocument(index, trigger) {
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…'); const result = state.results[index]; state.selected = trigger; [...$('results').children].forEach((item) => item.classList.remove('review__result--selected')); trigger.closest('.review__result')?.classList.add('review__result--selected'); setStatus('Загрузка документа…');
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); } try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
} }
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); }); $('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
let tutorialStep = 0; let tutorialReturnFocus = null;
function clearTourTarget() { $('tutorial-spotlight').hidden = true; $('document-panel').classList.remove('review__document--tour-visible'); }
function renderTutorial() { const step = TOUR_STEPS[tutorialStep]; const target = $(step.target); if (step.target === 'document-panel' && getComputedStyle(target).display === 'none') target.classList.add('review__document--tour-visible'); target.scrollIntoView({block: 'nearest', inline: 'nearest'}); const bounds = target.getBoundingClientRect(); $('tutorial-spotlight').style.cssText = `inset-block-start: ${Math.max(8, bounds.top - 4)}px; inset-inline-start: ${Math.max(8, bounds.left - 4)}px; inline-size: ${bounds.width + 8}px; block-size: ${bounds.height + 8}px;`; $('tutorial-spotlight').hidden = false; $('tutorial-heading').textContent = step.title; $('tutorial-text').textContent = step.text; $('tutorial-progress').textContent = step.label; $('tutorial-next').textContent = tutorialStep === TOUR_STEPS.length - 1 ? 'Понятно' : 'Далее'; }
function closeTutorial() { clearTourTarget(); $('tutorial-dialog').close(); markTourSeen(); tutorialReturnFocus?.focus(); }
function openTutorial() { tutorialReturnFocus = document.activeElement; tutorialStep = 0; renderTutorial(); $('tutorial-dialog').showModal(); $('tutorial-next').focus(); }
$('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; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } }); $('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft); document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } 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 fetch('/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(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } }); $('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } 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 fetch('/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(DRAFT_KEY); 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 = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; } const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
if (!hasSeenTour()) setTimeout(openTutorial, 350);
</script> </script>
</body> </body>
</html> </html>

View File

@@ -29,13 +29,41 @@ class SearchApiTest(unittest.TestCase):
with patch("search.api.request_json", return_value=response) as request: with patch("search.api.request_json", return_value=response) as request:
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active") status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload["results"][0]["snippet"], "<em>Закон</em>") self.assertEqual(payload["results"][0]["snippet"], "Закон")
body = json.loads(request.call_args.args[2]) body = json.loads(request.call_args.args[2])
self.assertEqual((body["from"], body["size"]), (5, 6)) self.assertEqual((body["from"], body["size"]), (5, 6))
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"]) self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
with self.assertRaisesRegex(ApiError, "within 10000 results"): with self.assertRaisesRegex(ApiError, "within 10000 results"):
api.handle("GET", "/search?q=x&page=100&page_size=100") api.handle("GET", "/search?q=x&page=100&page_size=100")
def test_production_api_uses_registration_intent_boosts(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
response = {"hits": {"hits": []}}
with patch("search.api.request_json", return_value=response) as request:
api.handle("GET", "/search?q=%D0%BA%D0%B0%D0%BA+%D0%BE%D1%82%D0%BA%D1%80%D1%8B%D1%82%D1%8C+%D0%9E%D1%81%D0%9E%D0%9E&language=ru")
body = json.loads(request.call_args.args[2])
planned = body["query"]["bool"]["must"]["bool"]
self.assertEqual(planned["minimum_should_match"], 1)
self.assertNotIn("filter", planned)
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"]))
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

@@ -5,7 +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 from search.query import COMPANY_REGISTRATION_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase): class SearchRelevanceTest(unittest.TestCase):
@@ -37,7 +37,15 @@ class SearchRelevanceTest(unittest.TestCase):
body = json.loads(build_search_body("ru", "трудовой договор", 10)) 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") self.assertIn({"term": {"is_current_edition": True}}, body["query"]["bool"]["filter"])
lexical_query = body["query"]["bool"]["must"]["bool"]["should"][0]["multi_match"]
self.assertEqual(lexical_query["type"], "cross_fields")
self.assertEqual(lexical_query["operator"], "AND")
def test_legal_form_abbreviations_expand_in_both_directions(self):
self.assertIn("как открыть общество с ограниченной ответственностью", query_variants("ru", "как открыть ОсОО"))
self.assertIn("регистрация осоо", [variant.casefold() for variant in query_variants("ru", "регистрация общества с ограниченной ответственностью")])
self.assertIn("жчк ачуу тартиби", [variant.casefold() for variant in query_variants("ky", "жоопкерчилиги чектелген коом ачуу тартиби")])
def test_company_registration_intent_boosts_current_documents(self): def test_company_registration_intent_boosts_current_documents(self):
for language, query, status in ( for language, query, status in (
@@ -45,13 +53,15 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК ачуу тартиби", "Күчүндө"), ("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
): ):
body = json.loads(build_search_body(language, query, 10)) body = json.loads(build_search_body(language, query, 10))
search_query = body["query"]["bool"] search_query = body["query"]["bool"]["must"]["bool"]
self.assertEqual(search_query["minimum_should_match"], 1) self.assertEqual(search_query["minimum_should_match"], 1)
boosts = [clause["constant_score"] for clause in search_query["should"][1:]] self.assertNotIn("filter", search_query)
self.assertEqual([item["boost"] for item in boosts], [2000, 1000]) self.assertTrue(all(clause.get("multi_match", {}).get("operator") == "OR" for clause in search_query["should"] if "multi_match" in clause))
boosts = [clause["constant_score"] for clause in search_query["should"] if "constant_score" in clause]
self.assertEqual([item["boost"] for item in boosts], [boost for _, boost in COMPANY_REGISTRATION_DOCUMENTS])
self.assertEqual( self.assertEqual(
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts], [item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
["230044970", "667"], [code for code, _ in COMPANY_REGISTRATION_DOCUMENTS],
) )
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts)) self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
@@ -60,6 +70,8 @@ class SearchRelevanceTest(unittest.TestCase):
("ru", "ОсОО зарегистрирован?"), ("ru", "ОсОО зарегистрирован?"),
("ru", "кто зарегистрировал ОсОО"), ("ru", "кто зарегистрировал ОсОО"),
("ru", "как открыть счет ОсОО"), ("ru", "как открыть счет ОсОО"),
("ru", "как открыть ОсОО банковский счет"),
("ru", "как открыть ОсОО банковский счёт"),
("ru", "как открыть филиал ОсОО"), ("ru", "как открыть филиал ОсОО"),
("ru", "как создать договор для ОсОО"), ("ru", "как создать договор для ОсОО"),
("ru", "порядок создания логотипа ОсОО"), ("ru", "порядок создания логотипа ОсОО"),
@@ -69,7 +81,8 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК кантип логотип түзүү"), ("ky", "ЖЧК кантип логотип түзүү"),
): ):
body = json.loads(build_search_body(language, query, 10)) body = json.loads(build_search_body(language, query, 10))
self.assertNotIn("should", body["query"]["bool"]) clauses = body["query"]["bool"]["must"]["bool"]["should"]
self.assertFalse(any("constant_score" in clause for clause in clauses))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -36,6 +36,9 @@ class SearchReviewTest(unittest.TestCase):
page = api.review_page() page = api.review_page()
self.assertIn("Оценка поисковой выдачи", page) self.assertIn("Оценка поисковой выдачи", page)
self.assertIn("akyldash-search-review-draft", page) self.assertIn("akyldash-search-review-draft", page)
self.assertIn("tutorial-dialog", page)
self.assertIn("Как это работает", page)
self.assertNotIn('id="error"', page)
self.assertIn("localStorage", page) self.assertIn("localStorage", page)

View File

@@ -39,4 +39,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.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.9.1` - Backend: `0.10.2`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
@@ -262,4 +262,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.1 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.10.2 · Frontend — не создан