Compare commits

...

6 Commits

11 changed files with 124 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@@ -12,11 +12,12 @@ from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels
from search.query import build_search_query
from search.reviews import ReviewSnapshots, ReviewStore
API_VERSION = "v1"
SEARCH_ALGORITHM_VERSION = "search-1"
SEARCH_ALGORITHM_VERSION = "search-3"
LANGUAGES = {"ru", "ky"}
CODE = re.compile(r"^[0-9]+$")
MAX_PAGE_SIZE = 100
@@ -139,7 +140,7 @@ class Api:
"from": (page - 1) * page_size,
"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"],
"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"},
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
}

View File

@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.9.4"
APP_VERSION = "0.10.1"
LANGUAGES = {"ru", "ky"}
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
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]:
patterns = {
"ru": (r"\bкак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\bкак\s+зарегистр\w*\s+осоо\b"),
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
non_registration_context = {
"ru": (r"\bсч[её]т\w*", r"\bбанк\w*", r"\bфилиал\w*", r"\bпредставительств\w*"),
"ky": (r"\bбанк\w*", r"\сеп\w*", r"\bфилиал\w*", r"\bөкүлчүлүк\w*"),
}[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 []
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:
full_text = {
"multi_match": {
"query": query,
"fields": [f"document_name_{language}", f"text_{language}"],
"type": "cross_fields",
}
}
clauses = company_registration_clauses(language, query)
bool_query = {"filter": {"term": {"language": language}}}
if clauses:
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
else:
bool_query["must"] = full_text
return json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {"bool": bool_query},
"query": {"bool": {"filter": [{"term": {"language": language}}, {"term": {"is_current_edition": True}}], "must": build_search_query(language, query)}},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()

View File

@@ -119,7 +119,7 @@
<div class="review__actions"><div class="review__actions-inner"><label>Ваше имя или псевдоним<input id="reviewer" maxlength="120" autocomplete="name" placeholder="Например, Айжан К."></label> <label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label> <button class="review__button--primary" id="save" type="button">Сохранить разметку</button></div></div>
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header">Акылдаш · Backend v0.9.4 · Внутренняя лаборатория релевантности</footer>
<footer class="review__header">Акылдаш · Backend v0.10.1 · Внутренняя лаборатория релевантности</footer>
<script>
const DRAFT_KEY = 'akyldash-search-review-draft';
const TOUR_KEY = 'akyldash-search-review-tour-seen';

View File

@@ -36,6 +36,19 @@ class SearchApiTest(unittest.TestCase):
with self.assertRaisesRegex(ApiError, "within 10000 results"):
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_document_editions_openapi_and_validation(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))

View File

@@ -5,7 +5,7 @@ from pathlib import Path
from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries
from search.query import build_search_body
from search.query import COMPANY_REGISTRATION_DOCUMENTS, build_search_body, query_variants
class SearchRelevanceTest(unittest.TestCase):
@@ -37,7 +37,15 @@ class SearchRelevanceTest(unittest.TestCase):
body = json.loads(build_search_body("ru", "трудовой договор", 10))
self.assertFalse(body["track_total_hits"])
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):
for language, query, status in (
@@ -45,13 +53,15 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
):
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)
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
self.assertNotIn("filter", search_query)
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(
[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))
@@ -60,6 +70,8 @@ class SearchRelevanceTest(unittest.TestCase):
("ru", "ОсОО зарегистрирован?"),
("ru", "кто зарегистрировал ОсОО"),
("ru", "как открыть счет ОсОО"),
("ru", "как открыть ОсОО банковский счет"),
("ru", "как открыть ОсОО банковский счёт"),
("ru", "как открыть филиал ОсОО"),
("ru", "как создать договор для ОсОО"),
("ru", "порядок создания логотипа ОсОО"),
@@ -69,7 +81,8 @@ class SearchRelevanceTest(unittest.TestCase):
("ky", "ЖЧК кантип логотип түзүү"),
):
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__":

View File

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

View File

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