Compare commits

...

13 Commits

Author SHA1 Message Date
85672dc041 docs: record frontend readiness roadmap 2026-08-20 23:25:12 +03:00
0f6f12e0e9 docs: add frontend design readiness plan 2026-08-20 23:23:19 +03:00
7a656cfc1a Merge pull request 'Исправить выдачу для открытия ОсОО и ЖЧК' (#14) from fix/company-registration-search into main
Reviewed-on: #14
2026-08-20 20:17:17 +00:00
72de364f29 docs: update changelog for search fix 2026-08-20 15:13:20 +03:00
74873451ab fix: match explicit company registration intents 2026-08-20 15:12:07 +03:00
2faac7b74e fix: narrow company registration intent 2026-08-20 15:10:32 +03:00
e3f08426d5 fix: expose curated search query 2026-08-20 15:08:42 +03:00
dc5399de0a fix: rank company registration queries 2026-08-20 15:04:55 +03:00
8f0f0e3fbf Merge pull request 'Улучшить ранжирование поиска по relevance set' (#13) from feature/search-relevance-ranking into main
Reviewed-on: #13
2026-08-18 08:35:10 +00:00
9ef8b099df docs: update changelog 2026-08-18 08:56:18 +03:00
9318474a54 docs: preserve status history 2026-08-18 08:55:12 +03:00
d701f714e1 fix: improve search relevance ranking 2026-08-18 08:51:22 +03:00
575f4fa3af Merge pull request 'Добавить инструкцию по разметке relevance set' (#12) from docs/relevance-annotation-guide into main
Reviewed-on: #12
2026-08-16 06:21:36 +00:00
19 changed files with 300 additions and 51 deletions

View File

@@ -2,7 +2,16 @@
## Не выпущено ## Не выпущено
- Добавлен roadmap готовности данных, поиска и API перед проектированием
frontend.
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
показываются действующие положение о регистрации и закон о хозяйственных
товариществах и обществах.
- Добавлен CLI `python3 -m search.query` для проверки текущей выдачи локального
OpenSearch.
- Добавлена инструкция по подготовке и независимой проверке relevance set. - Добавлена инструкция по подготовке и независимой проверке relevance set.
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
текст документа как единое поле.
## 0.5.0 — 2026-08-15 ## 0.5.0 — 2026-08-15

View File

@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние ## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.5.0`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную `0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
размеченном наборе запросов. размеченном наборе запросов.
| Компонент | Версия | Состояние | | Компонент | Версия | Состояние |
|---|---:|---| |---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния | | Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.5.0` | добавлена воспроизводимая оценка качества поиска | | Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
| Frontend | — | ещё не создан | | Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.5.0` | добавлены relevance set и baseline-метрики | | Сбор и обработка правовых данных | `0.5.2` | добавлены relevance set и baseline-метрики |
| RAG и база знаний | — | ещё не созданы | | RAG и база знаний | — | ещё не созданы |
## Структура репозитория ## Структура репозитория
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш # Backend Акылдаш
Версия: `0.5.0` Версия: `0.5.2`
Первая backend-область проекта — загрузка правовых документов из официального Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -189,6 +189,13 @@ PYTHONPATH=backend python3 -m search.evaluate_relevance \
Менять веса или анализаторы следует только после фиксации этого baseline и Менять веса или анализаторы следует только после фиксации этого baseline и
разбора ошибок выдачи. разбора ошибок выдачи.
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
```bash
PYTHONPATH=backend python3 -m search.query "как открыть ОсОО" --language ru
PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --language ky
```
--- ---
Акылдаш · Backend v0.5.0 · Frontend — не создан Акылдаш · Backend v0.5.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.5.0" APP_VERSION = "0.5.2"
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.5.0" APP_VERSION = "0.5.2"
SCHEMA_VERSION = "1" SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0" NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky") LANGUAGES = ("ru", "ky")

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import urllib.parse
from pathlib import Path from pathlib import Path
from search.minjust_opensearch import APP_VERSION, request_json from search.minjust_opensearch import APP_VERSION
from search.query import search_documents
def load_queries(path: Path) -> list[dict]: def load_queries(path: Path) -> list[dict]:
@@ -43,30 +43,7 @@ def load_queries(path: Path) -> list[dict]:
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]: def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
language = item["language"] return search_documents(base_url, index, item["language"], item["query"], top_k)
body = json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {
"bool": {
"filter": {"term": {"language": language}},
"must": {
"multi_match": {
"query": item["query"],
"fields": [f"document_name_{language}", f"text_{language}"],
}
},
}
},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
response = request_json(url, "POST", body, "application/json")
try:
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
except (KeyError, TypeError) as error:
raise RuntimeError("OpenSearch search response is incomplete") from error
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict: def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:

View File

@@ -15,7 +15,7 @@ import urllib.request
from pathlib import Path from pathlib import Path
from typing import Iterator from typing import Iterator
APP_VERSION = "0.5.0" APP_VERSION = "0.5.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")

90
backend/search/query.py Normal file
View File

@@ -0,0 +1,90 @@
"""Run document searches against the local OpenSearch index."""
from __future__ import annotations
import argparse
import json
import re
import urllib.parse
from search.minjust_opensearch import APP_VERSION, request_json
def company_registration_clauses(language: str, query: str) -> list[dict]:
patterns = {
"ru": (r"\ак\s+откры\w*\s+осоо\b", r"\b(порядок|процедура)\s+откры\w*\s+осоо\b", r"\ак\s+зарегистр\w*\s+осоо\b"),
"ky": (r"\bжчк\s+ач\w*\s+тартиби\b", r"\bжчк\s+кантип\s+ач\w*\b"),
}[language]
if not any(re.search(pattern, query.casefold()) for pattern in patterns):
return []
status = {"ru": "Действует", "ky": "Күчүндө"}[language]
# ponytail: curated legal mapping; replace with a reviewed intent catalog when coverage expands.
def clause(document_code: str, boost: int) -> dict:
return {
"constant_score": {
"filter": {
"bool": {
"filter": [
{"term": {"document_code": document_code}},
{"term": {f"status_{language}": status}},
]
}
},
"boost": boost,
}
}
return [clause("230044970", 2000), clause("667", 1000)]
def build_search_body(language: str, query: str, top_k: int) -> bytes:
full_text = {
"multi_match": {
"query": query,
"fields": [f"document_name_{language}", f"text_{language}"],
"type": "cross_fields",
}
}
clauses = company_registration_clauses(language, query)
bool_query = {"filter": {"term": {"language": language}}}
if clauses:
bool_query.update({"should": [full_text, *clauses], "minimum_should_match": 1})
else:
bool_query["must"] = full_text
return json.dumps({
"size": top_k,
"track_total_hits": False,
"_source": ["document_code"],
"query": {"bool": bool_query},
"collapse": {"field": "document_code"},
}, ensure_ascii=False).encode()
def search_documents(base_url: str, index: str, language: str, query: str, top_k: int) -> list[str]:
url = f"{base_url.rstrip('/')}/{urllib.parse.quote(index, safe='')}/_search"
response = request_json(url, "POST", build_search_body(language, query, top_k), "application/json")
try:
return [hit["_source"]["document_code"] for hit in response["hits"]["hits"]]
except (KeyError, TypeError) as error:
raise RuntimeError("OpenSearch search response is incomplete") from error
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("query")
parser.add_argument("--language", choices=("ru", "ky"), required=True)
parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--top-k", type=int, default=10)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from search.evaluate_relevance import evaluate, load_queries from search.evaluate_relevance import evaluate, load_queries
from search.query import build_search_body
class SearchRelevanceTest(unittest.TestCase): class SearchRelevanceTest(unittest.TestCase):
@@ -28,18 +29,47 @@ class SearchRelevanceTest(unittest.TestCase):
path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8") path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8")
loaded = load_queries(path) loaded = load_queries(path)
responses = [ with patch("search.evaluate_relevance.search_documents", side_effect=[["7", "10", "8"], ["10", "9"]]):
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["7", "10", "8"]]}},
{"hits": {"hits": [{"_source": {"document_code": code}} for code in ["10", "9"]]}},
]
with patch("search.evaluate_relevance.request_json", side_effect=responses) as request:
result = evaluate(loaded, "http://127.0.0.1:9200", "test", 10) result = evaluate(loaded, "http://127.0.0.1:9200", "test", 10)
self.assertEqual(result["summary"], {"query_count": 2, "recall_at_10": 1.0, "mrr_at_10": 0.75}) self.assertEqual(result["summary"], {"query_count": 2, "recall_at_10": 1.0, "mrr_at_10": 0.75})
self.assertEqual(result["queries"][0]["reciprocal_rank_at_10"], 1.0) self.assertEqual(result["queries"][0]["reciprocal_rank_at_10"], 1.0)
body = json.loads(request.call_args_list[0].args[2]) body = json.loads(build_search_body("ru", "трудовой договор", 10))
self.assertFalse(body["track_total_hits"]) self.assertFalse(body["track_total_hits"])
self.assertEqual(body["collapse"], {"field": "document_code"}) self.assertEqual(body["collapse"], {"field": "document_code"})
self.assertEqual(body["query"]["bool"]["must"]["multi_match"]["type"], "cross_fields")
def test_company_registration_intent_boosts_current_documents(self):
for language, query, status in (
("ru", "как открыть ОсОО", "Действует"),
("ky", "ЖЧК ачуу тартиби", "Күчүндө"),
):
body = json.loads(build_search_body(language, query, 10))
search_query = body["query"]["bool"]
self.assertEqual(search_query["minimum_should_match"], 1)
boosts = [clause["constant_score"] for clause in search_query["should"][1:]]
self.assertEqual([item["boost"] for item in boosts], [2000, 1000])
self.assertEqual(
[item["filter"]["bool"]["filter"][0]["term"]["document_code"] for item in boosts],
["230044970", "667"],
)
self.assertTrue(all(item["filter"]["bool"]["filter"][1] == {"term": {f"status_{language}": status}} for item in boosts))
def test_company_registration_intent_ignores_non_procedural_queries(self):
for language, query in (
("ru", "ОсОО зарегистрирован?"),
("ru", "кто зарегистрировал ОсОО"),
("ru", "как открыть счет ОсОО"),
("ru", "как открыть филиал ОсОО"),
("ru", "как создать договор для ОсОО"),
("ru", "порядок создания логотипа ОсОО"),
("ky", "ЖЧК ачык маалымат"),
("ky", "ЖЧК кантип банк эсебин ачуу"),
("ky", "ЖЧК кантип келишим түзүү"),
("ky", "ЖЧК кантип логотип түзүү"),
):
body = json.loads(build_search_body(language, query, 10))
self.assertNotIn("should", body["query"]["bool"])
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -6,6 +6,8 @@
правила работы с данными. правила работы с данными.
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы - [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
MVP, зависимости и спринты. MVP, зависимости и спринты.
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
обязательные работы и критерии перехода к frontend.
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) — - [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
требования и критерии приёмки нормализатора ЦБД Минюста КР. требования и критерии приёмки нормализатора ЦБД Минюста КР.
@@ -33,4 +35,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.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.5.0` - Backend: `0.5.2`
- Frontend: не создан - Frontend: не создан
## Краткий итог ## Краткий итог
@@ -135,6 +135,14 @@
## История изменений статуса ## История изменений статуса
### 2026-08-18
- Оценщик поиска использует `cross_fields` для совместного сопоставления
названия и текста документа.
- На размеченном наборе из 50 запросов Recall@10 вырос с `0.23` до `0.30`,
MRR@10с `0.1854` до `0.2272`.
- Версия backend обновлена до `0.5.1`.
### 2026-08-14 ### 2026-08-14
- Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K. - Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K.
@@ -226,4 +234,4 @@
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -0,0 +1,126 @@
# Готовность к проектированию frontend
Этот документ определяет обязательный результат до начала проектирования и
разработки пользовательского интерфейса. Он дополняет
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
спринты frontend, этот — критерий перехода к ним.
## Правило перехода
Проектирование frontend начинается, когда выполнены все обязательные пункты
этого плана и пройден финальный readiness gate. До этого не создаются
`frontend/`, макеты, UI-компоненты или mock-данные, заменяющие неготовый
backend-контракт.
Вне этого этапа остаются аккаунты, уведомления, RAG, судебная практика и
внешние коммерческие сервисы.
## 1. Данные и поисковый индекс
### Результат
В локальном OpenSearch доступен воспроизводимо собранный корпус актуальных
редакций ЦБД Минюста КР, пригодный для поиска на русском и кыргызском языках.
### Обязательные работы
1. Подтвердить для каждого индексируемого документа наличие канонических
реквизитов: код, редакция, язык, статус, тип, орган, дата, номер, название
и ссылка на официальный источник.
2. Зафиксировать правила для документов без текста и одноязычных редакций:
они не скрываются и не получают выдуманный перевод.
3. Проверить versioned-индекс, mapping, анализаторы RU/KY, полноту актуальных
редакций и процедуру безопасной переиндексации с переключением alias.
4. Описать и выполнить воспроизводимый сценарий обновления:
выгрузка → нормализация → новый индекс → проверка → переключение alias.
### Критерий приёмки
Команда может повторить обновление на чистом локальном окружении, проверить
количество документов и фрагментов, а затем безопасно переключить поисковый
alias без смешивания старого и нового корпуса.
## 2. Relevance set и качество поиска
### Результат
Есть замороженный набор `relevance set v1` из 50 практических запросов:
минимум 25 на русском и 25 на кыргызском языках.
### Обязательные работы
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
естественными формулировками пользователей и подтверждёнными
`document_code` релевантных действующих актов.
2. Проверить каждый запрос по официальной ЦБД и локальному индексу; не
включать запросы без установленного эталонного результата.
3. Провести независимую вторую проверку языка, формулировки и полного списка
кодов. Спорные результаты не включать до согласования.
4. После проверки сохранить неизменяемую копию набора и baseline
`Recall@10`/`MRR@10`; новые правила ранжирования оценивать только сравнением
с этим baseline.
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
практическом действии. Правило принимается, только если улучшает
подтверждённые запросы и не создаёт ложных срабатываний в негативных
сценариях.
### Критерий приёмки
Оценщик проходит на полном наборе, выводит метрики и выдачу для каждого
запроса; baseline и результаты его повторного запуска воспроизводимы.
## 3. Справочники и контракт API
### Результат
Frontend получает все юридически значимые данные через версионированный
OpenAPI-контракт, а не реконструирует их из текста фрагментов.
### Обязательные работы
1. Утвердить справочники v1: типы документов, органы принятия, статусы,
уровни действия и темы. Для каждого значения определить стабильный код и
подписи RU/KY.
2. Реализовать и описать OpenAPI для:
- `GET /search` — запрос, язык, фильтры, сортировка, серверная пагинация,
выдержка и подсветка;
- `GET /search/filters` — допустимые значения фильтров;
- `GET /documents/{code}` — карточка актуального документа;
- `GET /documents/{code}/editions` и
`GET /documents/{code}/editions/{edition}` — редакции и их содержимое.
3. Зафиксировать единые ответы для пустой выдачи, неизвестного документа,
недоступной редакции и ошибки upstream; для документов с одним языком
вернуть доступные языки явно.
4. Добавить контрактные и интеграционные проверки API на локальном OpenSearch:
поиск, фильтры, пагинация, сортировка, документ, редакции и одноязычные
акты.
### Критерий приёмки
OpenAPI опубликован вместе с backend, тестовый клиент получает реальные данные
по всем endpoint без mock-слоя, а результаты поиска открывают актуальный
документ и выбранную редакцию.
## 4. Финальный readiness gate
Перед началом frontend выполнить и зафиксировать один сквозной сценарий:
1. Обновить корпус из официальной ЦБД.
2. Нормализовать данные и собрать новый versioned-индекс.
3. Прогнать проверки индекса и relevance baseline.
4. Переключить alias на проверенный индекс.
5. Выполнить API-сценарии поиска, фильтрации, просмотра документа и редакции
на RU, KY и одноязычном документе.
Gate считается пройденным, если все проверки успешны, зафиксированы версии
backend и индекса, опубликованы известные ограничения и назначен ответственный
за юридическую проверку relevance set.
## Порядок issues
1. Собрать и независимо проверить relevance set v1.
2. Завершить проверку полноты данных и воспроизводимую переиндексацию с alias.
3. Утвердить справочники v1.
4. Реализовать OpenAPI и интеграционные проверки.
5. Провести финальный readiness gate и только затем открыть задачу на
проектирование frontend.

View File

@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

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

View File

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

View File

@@ -48,4 +48,4 @@ python3 -m unittest -v
--- ---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.0 · Frontend — не создан Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан