Compare commits
20 Commits
feature/lo
...
docs/front
| Author | SHA1 | Date | |
|---|---|---|---|
| 85672dc041 | |||
| 0f6f12e0e9 | |||
| 7a656cfc1a | |||
| 72de364f29 | |||
| 74873451ab | |||
| 2faac7b74e | |||
| e3f08426d5 | |||
| dc5399de0a | |||
| 8f0f0e3fbf | |||
| 9ef8b099df | |||
| 9318474a54 | |||
| d701f714e1 | |||
| 575f4fa3af | |||
| e6c4848a7f | |||
| 0a72d0d242 | |||
| d4ee8fac08 | |||
| 2d31e02753 | |||
| 8d4725231e | |||
| 424cc16497 | |||
| 38739543f4 |
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# История изменений
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Добавлен roadmap готовности данных, поиска и API перед проектированием
|
||||
frontend.
|
||||
- Исправлена выдача для явных запросов об открытии ОсОО и ЖЧК: первыми
|
||||
показываются действующие положение о регистрации и закон о хозяйственных
|
||||
товариществах и обществах.
|
||||
- Добавлен CLI `python3 -m search.query` для проверки текущей выдачи локального
|
||||
OpenSearch.
|
||||
- Добавлена инструкция по подготовке и независимой проверке relevance set.
|
||||
- Улучшено ранжирование relevance-оценки: запрос теперь сопоставляет название и
|
||||
текст документа как единое поле.
|
||||
|
||||
## 0.5.0 — 2026-08-15
|
||||
|
||||
- Добавлена воспроизводимая оценка качества поиска по Recall@10 и MRR@10.
|
||||
- Подготовлен шаблон relevance set из 25 русских и 25 кыргызских запросов с инструкцией по ручной разметке.
|
||||
10
README.md
10
README.md
@@ -10,15 +10,15 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.4.1`: возобновляемая загрузка индекса документов Министерства юстиции
|
||||
ЦБД Минюста КР.
|
||||
`0.5.2`: подготовка поискового индекса и оценка Recall@K/MRR@K на вручную
|
||||
размеченном наборе запросов.
|
||||
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.4.1` | добавлено продолжение прерванной Bulk-загрузки |
|
||||
| Backend | `0.5.2` | добавлено ранжирование подтверждённых запросов об открытии ОсОО/ЖЧК |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | `0.4.1` | добавлено продолжение загрузки существующего индекса |
|
||||
| Сбор и обработка правовых данных | `0.5.2` | добавлены relevance set и baseline-метрики |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
## Структура репозитория
|
||||
@@ -59,4 +59,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.4.1`
|
||||
Версия: `0.5.2`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -165,6 +165,37 @@ Security plugin отключён только для локальной разр
|
||||
также задаёт одну shard и ноль replicas; для production число shard следует
|
||||
рассчитать по размеру корпуса и настроить не менее одной replica.
|
||||
|
||||
## Оценка качества поиска
|
||||
|
||||
`search/relevance-set-v1.template.json` содержит заготовку для 25 русских и
|
||||
25 кыргызских запросов. Для каждого запроса человек должен указать реальную
|
||||
формулировку и коды всех релевантных документов; пустая или неполная разметка
|
||||
должна быть отклонена при ручной проверке, а технически некорректная — самим
|
||||
оценщиком. Критерии выбора запросов, релевантности и двойной проверки описаны в
|
||||
`search/RELEVANCE_ANNOTATION.md`. Рабочую копию следует хранить в игнорируемом
|
||||
каталоге `data/`, пока набор не проверен и не разрешён к публикации.
|
||||
|
||||
Baseline использует поля названия и текста соответствующего языка, оставляет в
|
||||
выдаче один результат на документ и вычисляет макро-средние Recall@10 и MRR@10:
|
||||
|
||||
```bash
|
||||
mkdir -p data/search
|
||||
cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.json
|
||||
PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
data/search/relevance-set-v1.json \
|
||||
> data/search/baseline-v1.json
|
||||
```
|
||||
|
||||
Менять веса или анализаторы следует только после фиксации этого baseline и
|
||||
разбора ошибок выдачи.
|
||||
|
||||
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.query "как открыть ОсОО" --language ru
|
||||
PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --language ky
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -21,7 +21,7 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
APP_VERSION = "0.4.1"
|
||||
APP_VERSION = "0.5.2"
|
||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||
|
||||
@@ -23,7 +23,7 @@ from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.4.1"
|
||||
APP_VERSION = "0.5.2"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
|
||||
106
backend/search/RELEVANCE_ANNOTATION.md
Normal file
106
backend/search/RELEVANCE_ANNOTATION.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Инструкция по разметке relevance set v1
|
||||
|
||||
## Цель
|
||||
|
||||
Набор проверяет, находит ли поиск нужные документы по реальным формулировкам
|
||||
пользователей. Он не должен подгоняться под текущую выдачу: сначала фиксируются
|
||||
запросы и релевантные документы, затем считается baseline и меняется
|
||||
ранжирование.
|
||||
|
||||
## Подготовка
|
||||
|
||||
Создайте игнорируемую Git рабочую копию:
|
||||
|
||||
```bash
|
||||
mkdir -p data/search
|
||||
cp backend/search/relevance-set-v1.template.json data/search/relevance-set-v1.json
|
||||
```
|
||||
|
||||
Сохраните идентификаторы `ru-01`–`ru-25` и `ky-01`–`ky-25`. Заполняйте
|
||||
`query` и `relevant_document_codes`; остальные поля и структуру JSON не меняйте.
|
||||
|
||||
## Выбор запросов
|
||||
|
||||
- Используйте 25 русских и 25 кыргызских запросов, реально заданных или
|
||||
сформулированных носителем языка для практической юридической задачи.
|
||||
- Не переводите русский набор дословно на кыргызский: оба набора должны
|
||||
отражать естественные формулировки своего языка.
|
||||
- Записывайте исходную формулировку без улучшения под поисковик. Допустимы
|
||||
разговорные слова, распространённые сокращения и опечатки.
|
||||
- Не используйте персональные данные, закрытые материалы и сведения, которых
|
||||
нет в публичном корпусе Минюста.
|
||||
- Не включайте запрос, если нельзя установить хотя бы один релевантный документ.
|
||||
- Не повторяйте один информационный запрос в нескольких близких формулировках.
|
||||
|
||||
Проверьте разнообразие набора: названия и номера актов, вопросы по жизненной
|
||||
или рабочей ситуации, короткие тематические запросы, органы принятия, статусы и
|
||||
даты. Это ориентир, а не квота: реальные запросы важнее искусственного баланса.
|
||||
|
||||
## Критерий релевантности
|
||||
|
||||
Документ релевантен, если его текст или реквизиты непосредственно отвечают
|
||||
информационной потребности запроса. Добавляйте все такие документы, а не только
|
||||
первый результат.
|
||||
|
||||
Не отмечайте документ релевантным только потому, что он:
|
||||
|
||||
- содержит отдельные слова запроса;
|
||||
- упоминает нужный акт без ответа на запрос;
|
||||
- относится к близкой теме;
|
||||
- является утратившей силу редакцией, когда запрос явно требует действующую
|
||||
норму, либо наоборот.
|
||||
|
||||
Если запрос допускает несколько самостоятельных правильных документов,
|
||||
добавьте коды каждого из них. Код берите из поля `document_code`, а не из ID
|
||||
фрагмента или редакции.
|
||||
|
||||
## Разметка одного запроса
|
||||
|
||||
1. До просмотра выдачи зафиксируйте информационную потребность и формулировку
|
||||
`query`.
|
||||
2. Найдите кандидатов в локальном OpenSearch и в официальной ЦБД Минюста.
|
||||
Проверьте исходный запрос, его короткий вариант и вариант с юридическим
|
||||
термином или известным номером акта.
|
||||
3. Просмотрите не только заголовки, но и текст, статус, дату и редакцию каждого
|
||||
кандидата.
|
||||
4. Запишите уникальные `document_code` всех документов, удовлетворяющих
|
||||
критерию релевантности.
|
||||
5. Повторите поиск по ключевым терминам найденных документов, чтобы обнаружить
|
||||
пропущенные альтернативные акты.
|
||||
|
||||
Пример структуры (код условный):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "ru-01",
|
||||
"language": "ru",
|
||||
"query": "как зарегистрировать общественное объединение",
|
||||
"relevant_document_codes": ["12345"]
|
||||
}
|
||||
```
|
||||
|
||||
## Проверка качества
|
||||
|
||||
Второй человек должен проверить формулировку, язык и полный список релевантных
|
||||
документов для каждого запроса. Спорные случаи обсуждаются до единого решения;
|
||||
результат голосования или непроверенную разметку в baseline не включайте.
|
||||
|
||||
Перед запуском убедитесь, что:
|
||||
|
||||
- заполнены ровно 50 записей: 25 `ru` и 25 `ky`;
|
||||
- все запросы непустые и различаются по информационной потребности;
|
||||
- у каждой записи есть хотя бы один уникальный `document_code`;
|
||||
- язык запроса совпадает с `language`;
|
||||
- JSON не содержит комментариев и дополнительных полей.
|
||||
|
||||
Оценщик дополнительно проверит структуру файла. После ручной проверки
|
||||
зафиксируйте копию набора и не меняйте её при настройке поиска:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.evaluate_relevance \
|
||||
data/search/relevance-set-v1.json \
|
||||
> data/search/baseline-v1.json
|
||||
```
|
||||
|
||||
Разбирайте запросы с низкими Recall@10 и MRR@10 по отдельности. Меняйте веса,
|
||||
анализаторы или словари только после сохранения исходного baseline.
|
||||
90
backend/search/evaluate_relevance.py
Normal file
90
backend/search/evaluate_relevance.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Measure document search Recall@K and MRR@K against a relevance set."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from search.minjust_opensearch import APP_VERSION
|
||||
from search.query import search_documents
|
||||
|
||||
|
||||
def load_queries(path: Path) -> list[dict]:
|
||||
with path.open(encoding="utf-8") as source:
|
||||
queries = json.load(source)
|
||||
if not isinstance(queries, list) or not queries:
|
||||
raise ValueError("Relevance set must be a non-empty JSON array")
|
||||
|
||||
seen = set()
|
||||
for item in queries:
|
||||
if not isinstance(item, dict) or set(item) != {
|
||||
"id", "language", "query", "relevant_document_codes"
|
||||
}:
|
||||
raise ValueError("Each query must contain id, language, query and relevant_document_codes")
|
||||
codes = item["relevant_document_codes"]
|
||||
if (
|
||||
not isinstance(item["id"], str)
|
||||
or not item["id"].strip()
|
||||
or item["id"] in seen
|
||||
or not isinstance(item["language"], str)
|
||||
or item["language"] not in {"ru", "ky"}
|
||||
or not isinstance(item["query"], str)
|
||||
or not item["query"].strip()
|
||||
or not isinstance(codes, list)
|
||||
or not codes
|
||||
or any(not isinstance(code, str) or not code for code in codes)
|
||||
or len(codes) != len(set(codes))
|
||||
):
|
||||
raise ValueError(f"Invalid relevance query: {item.get('id', '<unknown>')}")
|
||||
seen.add(item["id"])
|
||||
return queries
|
||||
|
||||
|
||||
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
|
||||
return search_documents(base_url, index, item["language"], item["query"], top_k)
|
||||
|
||||
|
||||
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:
|
||||
results = []
|
||||
for item in queries:
|
||||
retrieved = search(base_url, index, item, top_k)
|
||||
relevant = set(item["relevant_document_codes"])
|
||||
matches = [rank for rank, code in enumerate(retrieved, 1) if code in relevant]
|
||||
results.append({
|
||||
"id": item["id"],
|
||||
"language": item["language"],
|
||||
"query": item["query"],
|
||||
"retrieved_document_codes": retrieved,
|
||||
f"recall_at_{top_k}": len(relevant.intersection(retrieved)) / len(relevant),
|
||||
f"reciprocal_rank_at_{top_k}": 1 / matches[0] if matches else 0.0,
|
||||
})
|
||||
return {
|
||||
"summary": {
|
||||
"query_count": len(results),
|
||||
f"recall_at_{top_k}": sum(item[f"recall_at_{top_k}"] for item in results) / len(results),
|
||||
f"mrr_at_{top_k}": sum(item[f"reciprocal_rank_at_{top_k}"] for item in results) / len(results),
|
||||
},
|
||||
"queries": results,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("relevance_set", type=Path)
|
||||
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")
|
||||
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -15,7 +15,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
APP_VERSION = "0.4.1"
|
||||
APP_VERSION = "0.5.2"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
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())
|
||||
52
backend/search/relevance-set-v1.template.json
Normal file
52
backend/search/relevance-set-v1.template.json
Normal file
@@ -0,0 +1,52 @@
|
||||
[
|
||||
{"id": "ru-01", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-02", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-03", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-04", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-05", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-06", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-07", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-08", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-09", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-10", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-11", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-12", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-13", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-14", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-15", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-16", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-17", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-18", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-19", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-20", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-21", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-22", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-23", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-24", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ru-25", "language": "ru", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-01", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-02", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-03", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-04", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-05", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-06", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-07", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-08", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-09", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-10", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-11", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-12", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-13", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-14", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-15", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-16", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-17", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-18", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-19", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-20", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-21", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-22", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-23", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-24", "language": "ky", "query": "", "relevant_document_codes": []},
|
||||
{"id": "ky-25", "language": "ky", "query": "", "relevant_document_codes": []}
|
||||
]
|
||||
76
backend/test_search_relevance.py
Normal file
76
backend/test_search_relevance.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.evaluate_relevance import evaluate, load_queries
|
||||
from search.query import build_search_body
|
||||
|
||||
|
||||
class SearchRelevanceTest(unittest.TestCase):
|
||||
def test_loads_queries_and_calculates_document_metrics(self):
|
||||
queries = [
|
||||
{
|
||||
"id": "ru-01",
|
||||
"language": "ru",
|
||||
"query": "трудовой договор",
|
||||
"relevant_document_codes": ["7", "8"],
|
||||
},
|
||||
{
|
||||
"id": "ky-01",
|
||||
"language": "ky",
|
||||
"query": "эмгек келишими",
|
||||
"relevant_document_codes": ["9"],
|
||||
},
|
||||
]
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "queries.json"
|
||||
path.write_text(json.dumps(queries, ensure_ascii=False), encoding="utf-8")
|
||||
loaded = load_queries(path)
|
||||
|
||||
with patch("search.evaluate_relevance.search_documents", side_effect=[["7", "10", "8"], ["10", "9"]]):
|
||||
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["queries"][0]["reciprocal_rank_at_10"], 1.0)
|
||||
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")
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -6,6 +6,8 @@
|
||||
правила работы с данными.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
||||
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
||||
|
||||
@@ -33,4 +35,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.4.1`
|
||||
- Backend: `0.5.2`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
@@ -135,8 +135,19 @@
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 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
|
||||
|
||||
- Добавлены шаблон relevance set v1 и воспроизводимый расчёт Recall@K/MRR@K.
|
||||
- Версия backend обновлена до `0.5.0`.
|
||||
|
||||
- Полный локальный индекс содержит 56 295 965 фрагментов и успешно отвечает на
|
||||
RU/KY-запросы.
|
||||
- Добавлены атомарный checkpoint и безопасное продолжение прерванной загрузки
|
||||
@@ -223,4 +234,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
126
docs/product/frontend-design-readiness-plan.md
Normal file
126
docs/product/frontend-design-readiness-plan.md
Normal 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.
|
||||
@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.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.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
@@ -180,4 +180,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.4.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.4.1 · Frontend — не создан
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан
|
||||
|
||||
Reference in New Issue
Block a user