Исправить выдачу для открытия ОсОО и ЖЧК #14
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
## Не выпущено
|
## Не выпущено
|
||||||
|
|
||||||
|
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
|
||||||
|
показываются действующие положение о регистрации и закон о хозяйственных
|
||||||
|
товариществах и обществах.
|
||||||
|
- Добавлен CLI `python3 -m search.query` для проверки текущей выдачи локального
|
||||||
|
OpenSearch.
|
||||||
- Добавлена инструкция по подготовке и независимой проверке relevance set.
|
- Добавлена инструкция по подготовке и независимой проверке relevance set.
|
||||||
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
|
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
|
||||||
текст документа как единое поле.
|
текст документа как единое поле.
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
|
|||||||
## Текущее состояние
|
## Текущее состояние
|
||||||
|
|
||||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||||
`0.5.1`: подготовка поискового индекса и оценка 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.1` | добавлена воспроизводимая оценка качества поиска |
|
| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
|
||||||
| Frontend | — | ещё не создан |
|
| Frontend | — | ещё не создан |
|
||||||
| Сбор и обработка правовых данных | `0.5.1` | добавлены 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.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backend Акылдаш
|
# Backend Акылдаш
|
||||||
|
|
||||||
Версия: `0.5.1`
|
Версия: `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.1 · Frontend — не создан
|
Акылдаш · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -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.1"
|
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"}
|
||||||
|
|||||||
@@ -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.1"
|
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")
|
||||||
|
|||||||
@@ -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,31 +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}"],
|
|
||||||
"type": "cross_fields",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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:
|
||||||
|
|||||||
@@ -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.1"
|
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
90
backend/search/query.py
Normal 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"\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"),
|
||||||
|
}[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())
|
||||||
@@ -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,20 +29,48 @@ 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")
|
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__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -33,4 +33,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -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.1`
|
- Backend: `0.5.2`
|
||||||
- Frontend: не создан
|
- Frontend: не создан
|
||||||
|
|
||||||
## Краткий итог
|
## Краткий итог
|
||||||
@@ -234,4 +234,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -214,4 +214,4 @@ python3 backend/normalization/minjust_cbd.py
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -44,4 +44,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -180,4 +180,4 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ python3 -m unittest -v
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.1 · Frontend — не создан
|
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||||
|
|||||||
Reference in New Issue
Block a user