Compare commits

..

24 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
e6c4848a7f docs: update changelog 2026-08-16 09:13:05 +03:00
0a72d0d242 docs: add relevance annotation guide 2026-08-16 09:10:36 +03:00
d4ee8fac08 Merge pull request 'Добавить baseline оценки релевантности поиска' (#11) from feature/search-relevance-baseline into main
Reviewed-on: #11
2026-08-15 04:04:55 +00:00
2d31e02753 docs: add project changelog 2026-08-15 00:17:59 +03:00
8d4725231e docs: clarify relevance review 2026-08-14 23:40:26 +03:00
424cc16497 feat: add search relevance baseline 2026-08-14 23:37:52 +03:00
38739543f4 Merge pull request 'Добавить локальный OpenSearch и возобновляемую загрузку' (#10) from feature/local-opensearch-lab into main
Reviewed-on: #10
2026-08-14 15:51:33 +00:00
3a4450fcfe feat: add resumable local OpenSearch loading 2026-08-14 18:32:23 +03:00
dddb07f393 Merge pull request 'Подготовить индекс фрагментов OpenSearch' (#9) from feature/opensearch-index-foundation into main
Reviewed-on: #9
2026-08-13 05:46:46 +00:00
ac5ed95f3a feat: prepare OpenSearch fragment index 2026-08-13 00:39:37 +03:00
30065925e4 Merge pull request 'Добавить нормализацию документов ЦБД Минюста КР' (#8) from feature/minjust-document-normalization into main
Reviewed-on: #8
2026-08-12 06:54:18 +00:00
25 changed files with 1489 additions and 25 deletions

19
CHANGELOG.md Normal file
View 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 кыргызских запросов с инструкцией по ручной разметке.

View File

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

View File

@@ -1,6 +1,6 @@
# Backend Акылдаш
Версия: `0.2.2`
Версия: `0.5.2`
Первая backend-область проекта — загрузка правовых документов из официального
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
@@ -96,8 +96,106 @@ data/minjust-normalized/
```bash
PYTHONPATH=backend python3 -m unittest backend/test_minjust_cbd.py -v
PYTHONPATH=backend python3 -m unittest backend/test_minjust_normalization.py -v
PYTHONPATH=backend python3 -m unittest backend/test_minjust_opensearch.py -v
```
## Подготовка индекса OpenSearch
Mapping поискового индекса находится в
`search/minjust-fragments-index.json`. Для кыргызского текста он использует
`icu_analyzer`, поэтому в OpenSearch должен быть установлен плагин
`analysis-icu`.
Потоковый экспорт в формат Bulk API без внешних Python-зависимостей:
```bash
python3 backend/search/minjust_opensearch.py
```
По умолчанию создаётся `data/opensearch/minjust-fragments.ndjson`. Экспорт
атомарный и детерминированный; для проверки можно передать `--limit 1`.
Для прямой загрузки без большого промежуточного файла используется `--url`:
```bash
python3 backend/search/minjust_opensearch.py \
--url http://127.0.0.1:9200 \
--index akyldash-fragments-dev-v1 \
--limit 1
```
Запросы Bulk API ограничены 25 МБ и не разрывают пару action/source. Для
полного прохода убрать `--limit` и выбрать новое имя версионного индекса.
После проверки production-индекса следует переключать alias, чтобы удалённые
фрагменты не оставались в поиске.
После каждого принятого Bulk-пакета загрузчик атомарно сохраняет checkpoint и
печатает код безопасного возобновления. При временных HTTP 429/5xx, timeout и
обрыве соединения запрос повторяется автоматически. Прерванную загрузку можно
продолжить без ручного выбора документа:
```bash
python3 backend/search/minjust_opensearch.py \
--url http://127.0.0.1:9200 \
--index akyldash-fragments-v1 \
--resume
```
По умолчанию checkpoint хранится в
`data/opensearch/<index>.checkpoint.json`; путь можно изменить через
`--checkpoint`. Checkpoint привязан к URL, cluster UUID, index UUID, `--limit`
и SHA-256 нормализованного manifest. Resume отклоняется при любом несовпадении:
для обновлённого корпуса или пересозданного индекса нужно создать новый
версионный индекс, проверить его и переключить alias. Это не оставляет
удалённые trailing-фрагменты старых документов.
## Локальный OpenSearch
Стенд использует один узел OpenSearch без Dashboards, устанавливает
`analysis-icu`, выделяет JVM 8 ГБ и доступен только на `127.0.0.1:9200`.
Индекс хранится в `data/opensearch-node` на диске проекта.
```bash
sudo sysctl -w vm.max_map_count=262144
docker compose -f deploy/local-opensearch/compose.yaml up -d --build
curl http://127.0.0.1:9200/_cluster/health
```
Security plugin отключён только для локальной разработки; этот compose нельзя
публиковать в сеть или использовать в production. Mapping локального стенда
также задаёт одну 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.2.2 · Frontend — не создан
Акылдаш · Backend v0.5.2 · Frontend — не создан

View File

@@ -21,7 +21,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Iterable
APP_VERSION = "0.2.2"
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"}

View File

@@ -23,7 +23,7 @@ from pathlib import Path
from typing import Callable
from urllib.parse import urlsplit
APP_VERSION = "0.2.2"
APP_VERSION = "0.5.2"
SCHEMA_VERSION = "1"
NORMALIZER_VERSION = "1.0.0"
LANGUAGES = ("ru", "ky")

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

View 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())

View File

@@ -0,0 +1,35 @@
{
"settings": {
"index": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "30s"
}
},
"mappings": {
"dynamic": "strict",
"properties": {
"schema_version": { "type": "keyword" },
"document_code": { "type": "keyword" },
"edition_code": { "type": "keyword" },
"language": { "type": "keyword" },
"position": { "type": "integer" },
"fragment_type": { "type": "keyword" },
"text_ru": { "type": "text", "analyzer": "russian" },
"text_ky": { "type": "text", "analyzer": "icu_analyzer" },
"document_name_ru": { "type": "text", "analyzer": "russian", "fields": { "keyword": { "type": "keyword", "ignore_above": 1024 } } },
"document_name_ky": { "type": "text", "analyzer": "icu_analyzer", "fields": { "keyword": { "type": "keyword", "ignore_above": 1024 } } },
"document_type_ru": { "type": "keyword" },
"document_type_ky": { "type": "keyword" },
"status_ru": { "type": "keyword" },
"status_ky": { "type": "keyword" },
"number": { "type": "keyword" },
"date_adopted": { "type": "date", "format": "strict_date" },
"authority_paths_ru": { "type": "keyword", "ignore_above": 2048 },
"authority_paths_ky": { "type": "keyword", "ignore_above": 2048 },
"source_path": { "type": "keyword", "index": false },
"source_sha256": { "type": "keyword", "index": false },
"text_sha256": { "type": "keyword", "index": false }
}
}
}

View File

@@ -0,0 +1,413 @@
"""Export normalized Ministry fragments for the OpenSearch Bulk API."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Iterator
APP_VERSION = "0.5.2"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
def read_json(path: Path):
def reject_constant(value: str):
raise ValueError(f"Invalid JSON constant: {value}")
for attempt in range(3):
try:
with path.open(encoding="utf-8") as source:
return json.load(source, parse_constant=reject_constant)
except (OSError, UnicodeError, json.JSONDecodeError) as error:
if attempt == 2:
raise ValueError(f"Cannot read JSON {path}: {error}") from error
time.sleep(0.1)
def localized(value: object, language: str):
return value.get(language) if isinstance(value, dict) else None
def paths(document: dict, field: str, language: str) -> list[str]:
return [" > ".join(item[language]) for item in document.get(field, []) if item.get(language)]
def search_document(document: dict, fragment: dict, expected: tuple[str, str, str, int]) -> dict:
document_code, edition_code, language, position = expected
fragment_id = f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}"
required = ("id", "document_code", "edition_code", "language", "position", "type", "text", "text_sha256", "source_path", "source_sha256")
if not isinstance(fragment, dict) or any(key not in fragment for key in required):
raise ValueError(f"Incomplete fragment {fragment_id}")
if (fragment["document_code"], fragment["edition_code"], fragment["language"], fragment["position"], fragment["id"]) != (*expected, fragment_id):
raise ValueError(f"Fragment identity does not match its path: {fragment_id}")
if language not in LANGUAGES or not isinstance(fragment["text"], str) or not fragment["text"]:
raise ValueError(f"Invalid fragment content: {fragment_id}")
for key in ("text_sha256", "source_sha256"):
value = fragment[key]
if not isinstance(value, str) or len(value) != 64 or any(char not in "0123456789abcdef" for char in value):
raise ValueError(f"Invalid {key}: {fragment_id}")
if hashlib.sha256(fragment["text"].encode()).hexdigest() != fragment["text_sha256"]:
raise ValueError(f"Text checksum mismatch: {fragment_id}")
dates = document.get("dates") or {}
result = {
"schema_version": document["schema_version"],
"document_code": document_code,
"edition_code": edition_code,
"language": language,
"position": position,
"fragment_type": fragment["type"],
f"text_{language}": fragment["text"],
"document_name_ru": localized(document.get("name"), "ru"),
"document_name_ky": localized(document.get("name"), "ky"),
"document_type_ru": localized(document.get("type"), "ru"),
"document_type_ky": localized(document.get("type"), "ky"),
"status_ru": localized(document.get("status"), "ru"),
"status_ky": localized(document.get("status"), "ky"),
"number": document.get("number"),
"date_adopted": dates.get("DateAdopted"),
"authority_paths_ru": paths(document, "authority_paths", "ru"),
"authority_paths_ky": paths(document, "authority_paths", "ky"),
"source_path": fragment["source_path"],
"source_sha256": fragment["source_sha256"],
"text_sha256": fragment["text_sha256"],
}
return {key: value for key, value in result.items() if value is not None}
def document_codes(input_root: Path, limit: int | None = None, start_at: str | None = None) -> list[str]:
connection = sqlite3.connect(f"file:{input_root / 'manifest.sqlite3'}?mode=ro", uri=True)
try:
query = "SELECT code FROM documents WHERE state = 'success' ORDER BY CAST(code AS INTEGER), code"
parameters: list[int] = []
if limit is not None:
query += " LIMIT ?"
parameters.append(limit)
codes = [str(code) for (code,) in connection.execute(query, parameters)]
finally:
connection.close()
if start_at is None:
return codes
try:
return codes[codes.index(start_at):]
except ValueError as error:
raise ValueError(f"Resume document not found in selected range: {start_at}") from error
def bulk_pairs(
input_root: Path,
index: str,
limit: int | None = None,
start_at: str | None = None,
) -> Iterator[tuple[str, bytes]]:
document_root = input_root / "documents"
if not document_root.is_dir():
raise FileNotFoundError(f"Document directory not found: {document_root}")
for code in document_codes(input_root, limit, start_at):
directory = document_root / code
document = read_json(directory / "document.json")
if document.get("source_code") != directory.name:
raise ValueError(f"Document identity does not match its path: {directory}")
for fragment_path in sorted(directory.glob("editions/*/*/fragments.json")):
edition_code, language = fragment_path.parts[-3:-1]
values = read_json(fragment_path)
if not isinstance(values, list):
raise ValueError(f"Fragments must be a list: {fragment_path}")
for position, fragment in enumerate(values, 1):
source = search_document(document, fragment, (directory.name, edition_code, language, position))
action = json.dumps({"index": {"_index": index, "_id": fragment["id"]}}, ensure_ascii=False, allow_nan=False)
body = json.dumps(source, ensure_ascii=False, allow_nan=False)
yield directory.name, f"{action}\n{body}\n".encode()
def document_count(input_root: Path, limit: int | None, start_at: str | None = None) -> int:
return len(document_codes(input_root, limit, start_at))
def bulk_batches(pairs: Iterator[tuple[str, bytes]], maximum_bytes: int) -> Iterator[tuple[str, bytes]]:
batch = bytearray()
last_code = ""
for code, pair in pairs:
if len(pair) > maximum_bytes:
raise ValueError(f"One Bulk pair exceeds the {maximum_bytes}-byte batch limit")
if batch and len(batch) + len(pair) > maximum_bytes:
yield last_code, bytes(batch)
batch.clear()
last_code = code
batch.extend(pair)
if batch:
yield last_code, bytes(batch)
def export_bulk(input_root: Path, output: Path, index: str, limit: int | None = None) -> tuple[int, int]:
source = input_root.resolve()
destination = output.resolve()
if destination == source or destination.is_relative_to(source):
raise ValueError("--output must not be inside --input")
output.parent.mkdir(parents=True, exist_ok=True)
fragments = 0
with tempfile.NamedTemporaryFile("wb", dir=output.parent, delete=False) as target:
temporary = Path(target.name)
try:
for code, pair in bulk_pairs(input_root, index, limit):
target.write(pair)
fragments += 1
target.flush()
os.fsync(target.fileno())
os.replace(temporary, output)
except BaseException:
temporary.unlink(missing_ok=True)
raise
return document_count(input_root, limit), fragments
def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_json_atomic(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("wb", dir=path.parent, delete=False) as target:
temporary = Path(target.name)
try:
target.write((json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode())
target.flush()
os.fsync(target.fileno())
os.replace(temporary, path)
except BaseException:
temporary.unlink(missing_ok=True)
raise
def request_json(
url: str,
method: str,
body: bytes | None,
content_type: str,
attempts: int = 5,
retry_invalid_json: bool = False,
) -> dict:
request = urllib.request.Request(url, data=body, method=method, headers={"Content-Type": content_type})
for attempt in range(attempts):
try:
with urllib.request.urlopen(request, timeout=120) as response:
return json.load(response)
except (UnicodeError, json.JSONDecodeError) as error:
if not retry_invalid_json or attempt == attempts - 1:
raise RuntimeError(f"{method} {url} returned invalid JSON: {error}") from error
delay = 2**attempt
except urllib.error.HTTPError as error:
response_body = error.read(500).decode("utf-8", errors="replace")
error.close()
retryable = error.code == 429 or 500 <= error.code < 600
if not retryable or attempt == attempts - 1:
raise RuntimeError(
f"{method} {url} failed with HTTP {error.code}: {response_body}"
) from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after and retry_after.isdigit() else 2**attempt
except (urllib.error.URLError, TimeoutError, ConnectionResetError) as error:
if attempt == attempts - 1:
raise RuntimeError(f"{method} {url} failed after {attempts} attempts: {error}") from error
delay = 2**attempt
time.sleep(delay)
raise AssertionError("unreachable")
def opensearch_identity(base: str, index: str, index_url: str) -> tuple[str, str]:
cluster = request_json(f"{base}/", "GET", None, "application/json")
definition = request_json(index_url, "GET", None, "application/json")
try:
return cluster["cluster_uuid"], definition[index]["settings"]["index"]["uuid"]
except (KeyError, TypeError) as error:
raise RuntimeError("OpenSearch identity response is incomplete") from error
def checkpoint_state(
input_root: Path,
index: str,
checkpoint: Path,
resume: bool,
url: str,
cluster_uuid: str,
index_uuid: str,
limit: int | None,
) -> tuple[dict, str | None]:
source = input_root.resolve()
destination = checkpoint.resolve()
if destination == source or destination.is_relative_to(source):
raise ValueError("--checkpoint must not be inside --input")
manifest_sha256 = file_sha256(input_root / "manifest.sqlite3")
if not resume:
return {
"schema_version": 1,
"url": url,
"cluster_uuid": cluster_uuid,
"index": index,
"index_uuid": index_uuid,
"input": str(source),
"manifest_sha256": manifest_sha256,
"limit": limit,
"last_document_code": None,
"complete": False,
}, None
state = read_json(checkpoint)
expected = {
"schema_version",
"url",
"cluster_uuid",
"index",
"index_uuid",
"input",
"manifest_sha256",
"limit",
"last_document_code",
"complete",
}
if not isinstance(state, dict) or set(state) != expected:
raise ValueError(f"Invalid checkpoint: {checkpoint}")
if (
state["schema_version"] != 1
or state["url"] != url
or state["cluster_uuid"] != cluster_uuid
or state["index"] != index
or state["index_uuid"] != index_uuid
or state["input"] != str(source)
):
raise ValueError(f"Checkpoint does not match this load: {checkpoint}")
if state["limit"] != limit:
raise ValueError(f"Checkpoint limit does not match --limit: {checkpoint}")
if state["manifest_sha256"] != manifest_sha256:
raise ValueError("Normalized manifest changed; create a new versioned index")
if state["complete"] is not False:
raise ValueError(f"Checkpoint is already complete: {checkpoint}")
start_at = state["last_document_code"]
if start_at is not None and not isinstance(start_at, str):
raise ValueError(f"Invalid checkpoint document code: {checkpoint}")
return state, start_at
def load_bulk(
input_root: Path,
url: str,
index: str,
mapping: Path = DEFAULT_MAPPING,
maximum_bytes: int = 25 * 1024 * 1024,
limit: int | None = None,
resume: bool = False,
checkpoint: Path = Path("data/opensearch/minjust-fragments.checkpoint.json"),
) -> tuple[int, int]:
base = url.rstrip("/")
index_url = f"{base}/{urllib.parse.quote(index, safe='')}"
if resume:
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
else:
request_json(index_url, "PUT", mapping.read_bytes(), "application/json")
cluster_uuid, index_uuid = opensearch_identity(base, index, index_url)
state, start_at = checkpoint_state(
input_root,
index,
checkpoint,
resume,
base,
cluster_uuid,
index_uuid,
limit,
)
documents = document_count(input_root, limit, start_at)
if not resume:
write_json_atomic(checkpoint, state)
fragments = 0
for last_code, batch in bulk_batches(bulk_pairs(input_root, index, limit, start_at), maximum_bytes):
result = request_json(
f"{base}/_bulk",
"POST",
batch,
"application/x-ndjson",
retry_invalid_json=True,
)
expected = batch.count(b"\n") // 2
items = result.get("items", [])
if result.get("errors"):
failures = [item.get("index", {}) for item in items if item.get("index", {}).get("error")]
details = "; ".join(
f"{item.get('_id', '<unknown>')}: {item['error'].get('type', 'error')}: "
f"{item['error'].get('reason', '<no reason>')}"
for item in failures[:5]
)
raise RuntimeError(
f"OpenSearch Bulk API failed for {len(failures)} item(s); "
f"resume from {checkpoint}: {details}"
)
if len(items) != expected:
raise RuntimeError(
f"OpenSearch Bulk API returned {len(items)} of {expected} item result(s); "
f"resume from {checkpoint}"
)
fragments += len(items)
state["last_document_code"] = last_code
write_json_atomic(checkpoint, state)
print(f"checkpoint={last_code} fragments={fragments}", flush=True)
state["complete"] = True
write_json_atomic(checkpoint, state)
return documents, fragments
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path, default=Path("data/minjust-normalized"))
parser.add_argument("--output", type=Path, default=Path("data/opensearch/minjust-fragments.ndjson"))
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--limit", type=int)
parser.add_argument("--url", help="create the index and stream bounded Bulk requests instead of writing a file")
parser.add_argument("--mapping", type=Path, default=DEFAULT_MAPPING)
parser.add_argument("--batch-mb", type=int, default=25)
parser.add_argument("--resume", action="store_true", help="load into an existing index")
parser.add_argument("--checkpoint", type=Path, help="persistent resume checkpoint path")
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
if arguments.limit is not None and arguments.limit <= 0:
raise SystemExit("--limit must be greater than zero")
if arguments.batch_mb <= 0:
raise SystemExit("--batch-mb must be greater than zero")
if arguments.resume and not arguments.url:
raise SystemExit("--resume requires --url")
if arguments.checkpoint and not arguments.url:
raise SystemExit("--checkpoint requires --url")
if arguments.url:
checkpoint = arguments.checkpoint or Path("data/opensearch") / f"{arguments.index}.checkpoint.json"
documents, fragments = load_bulk(
arguments.input,
arguments.url,
arguments.index,
arguments.mapping,
arguments.batch_mb * 1024 * 1024,
arguments.limit,
arguments.resume,
checkpoint,
)
destination = arguments.url
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash Backend v{APP_VERSION} · Frontend — not created")
return 0
if __name__ == "__main__":
raise SystemExit(main())

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

@@ -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": []}
]

View File

@@ -0,0 +1,295 @@
import hashlib
import io
import json
import sqlite3
import tempfile
import unittest
import urllib.error
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
from search.minjust_opensearch import bulk_batches, document_codes, export_bulk, load_bulk, request_json
class MinjustOpenSearchTest(unittest.TestCase):
def test_exports_atomic_bulk_and_rejects_mismatched_fragment(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
document_root = root / "normalized/documents/7"
document_root.mkdir(parents=True)
with closing(sqlite3.connect(root / "normalized/manifest.sqlite3")) as connection:
with connection:
connection.execute("CREATE TABLE documents (code TEXT, state TEXT)")
connection.execute("INSERT INTO documents VALUES ('7', 'success')")
connection.execute("INSERT INTO documents VALUES ('8', 'success')")
(document_root / "document.json").write_text(
json.dumps(
{
"schema_version": "1",
"source_code": "7",
"name": {"ru": "Закон", "ky": "Мыйзам"},
"type": {"ru": "Закон", "ky": "Мыйзам"},
"status": {"ru": "Действует", "ky": "Күчүндө"},
"number": "1",
"dates": {"DateAdopted": "2026-01-01"},
"authority_paths": [{"ru": ["Кабинет"], "ky": ["Кабинет"]}],
},
ensure_ascii=False,
),
encoding="utf-8",
)
empty = root / "normalized/documents/8"
empty.mkdir()
(empty / "document.json").write_text(
json.dumps({"schema_version": "1", "source_code": "8"}), encoding="utf-8"
)
for language, text in (("ru", "Текст \"RU\"\nстрока"), ("ky", "Кыргызча текст")):
path = document_root / f"editions/10/{language}/fragments.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps(
[{
"id": f"document:7:edition:10:lang:{language}:fragment:1",
"document_code": "7",
"edition_code": "10",
"language": language,
"position": 1,
"type": "paragraph",
"text": text,
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
"source_path": f"documents/7/editions/10/{language}.html",
"source_sha256": "b" * 64,
}],
ensure_ascii=False,
),
encoding="utf-8",
)
output = root / "bulk.ndjson"
self.assertEqual(export_bulk(root / "normalized", output, "test-index"), (2, 2))
content = output.read_bytes()
self.assertTrue(content.endswith(b"\n"))
lines = [json.loads(line) for line in content.splitlines()]
self.assertEqual(len(lines), 4)
self.assertEqual(lines[0]["index"]["_id"], "document:7:edition:10:lang:ky:fragment:1")
self.assertIn("text_ky", lines[1])
self.assertNotIn("text_ru", lines[1])
self.assertEqual(lines[3]["text_ru"], "Текст \"RU\"\nстрока")
self.assertEqual(
list(bulk_batches(iter((("7", b"a\nb\n"), ("8", b"c\nd\n"))), 4)),
[("7", b"a\nb\n"), ("8", b"c\nd\n")],
)
self.assertEqual(document_codes(root / "normalized", 2, "8"), ["8"])
with self.assertRaisesRegex(ValueError, "selected range"):
document_codes(root / "normalized", 1, "8")
checkpoint = root / "checkpoint.json"
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{},
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
{"errors": False, "items": [{"index": {}}, {"index": {}}]},
]
self.assertEqual(
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
checkpoint=checkpoint,
),
(2, 2),
)
self.assertEqual(request.call_args_list[-1].args[3], "application/x-ndjson")
state = json.loads(checkpoint.read_text(encoding="utf-8"))
self.assertEqual(state["last_document_code"], "7")
self.assertTrue(state["complete"])
state["last_document_code"] = "8"
state["complete"] = False
checkpoint.write_text(json.dumps(state), encoding="utf-8")
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-2"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "does not match"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "limit"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
limit=1,
resume=True,
checkpoint=checkpoint,
)
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
self.assertEqual(
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
),
(1, 0),
)
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list))
state["last_document_code"] = "9"
state["complete"] = False
checkpoint.write_text(json.dumps(state), encoding="utf-8")
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "Resume document not found"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
)
failed_checkpoint = root / "failed-checkpoint.json"
failure = {
"errors": True,
"items": [{"index": {"_id": "bad-id", "error": {"type": "mapper", "reason": "bad value"}}}],
}
failed_requests = [
{},
{"cluster_uuid": "cluster-1"},
{"failed-index": {"settings": {"index": {"uuid": "index-2"}}}},
failure,
]
with patch("search.minjust_opensearch.request_json", side_effect=failed_requests):
with self.assertRaisesRegex(RuntimeError, "bad-id: mapper: bad value"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"failed-index",
maximum_bytes=4096,
checkpoint=failed_checkpoint,
)
failed_state = json.loads(failed_checkpoint.read_text(encoding="utf-8"))
self.assertIsNone(failed_state["last_document_code"])
self.assertFalse(failed_state["complete"])
state["last_document_code"] = "8"
state["complete"] = False
checkpoint.write_text(json.dumps(state), encoding="utf-8")
with closing(sqlite3.connect(root / "normalized/manifest.sqlite3")) as connection:
with connection:
connection.execute("INSERT INTO documents VALUES ('9', 'success')")
with patch("search.minjust_opensearch.request_json") as request:
request.side_effect = [
{"cluster_uuid": "cluster-1"},
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
]
with self.assertRaisesRegex(ValueError, "manifest changed"):
load_bulk(
root / "normalized",
"http://127.0.0.1:9200",
"test-index",
maximum_bytes=4096,
resume=True,
checkpoint=checkpoint,
)
http_error = urllib.error.HTTPError(
"http://127.0.0.1:9200/test",
429,
"busy",
{},
io.BytesIO(b"busy"),
)
with (
patch("search.minjust_opensearch.urllib.request.urlopen", side_effect=[http_error, io.BytesIO(b"{}")]),
patch("search.minjust_opensearch.time.sleep") as sleep,
):
self.assertEqual(
request_json("http://127.0.0.1:9200/test", "GET", None, "application/json", attempts=2),
{},
)
sleep.assert_called_once_with(1)
with (
patch(
"search.minjust_opensearch.urllib.request.urlopen",
side_effect=[io.BytesIO(b"{"), io.BytesIO(b"{}")],
),
patch("search.minjust_opensearch.time.sleep") as sleep,
):
self.assertEqual(
request_json(
"http://127.0.0.1:9200/_bulk",
"POST",
b"{}\n{}\n",
"application/x-ndjson",
attempts=2,
retry_invalid_json=True,
),
{},
)
sleep.assert_called_once_with(1)
bad = document_root / "editions/10/ru/fragments.json"
fragments = json.loads(bad.read_text(encoding="utf-8"))
fragments[0]["document_code"] = "8"
bad.write_text(json.dumps(fragments, ensure_ascii=False), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "identity"):
export_bulk(root / "normalized", output, "test-index")
self.assertEqual(output.read_bytes(), content)
fragments[0]["document_code"] = "7"
fragments[0]["text"] = "Повреждено"
bad.write_text(json.dumps(fragments, ensure_ascii=False), encoding="utf-8")
with self.assertRaisesRegex(ValueError, "checksum"):
export_bulk(root / "normalized", output, "test-index")
with self.assertRaisesRegex(ValueError, "inside --input"):
export_bulk(root / "normalized", root / "normalized/manifest.sqlite3", "test-index")
self.assertEqual(output.read_bytes(), content)
bad.write_text("", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "fragments.json"):
export_bulk(root / "normalized", output, "test-index")
self.assertEqual(output.read_bytes(), content)
definition = json.loads(
(Path(__file__).parent / "search/minjust-fragments-index.json").read_text(encoding="utf-8")
)
mapping = definition["mappings"]
self.assertEqual(definition["settings"]["index"]["number_of_shards"], 1)
self.assertEqual(definition["settings"]["index"]["number_of_replicas"], 0)
self.assertEqual(mapping["dynamic"], "strict")
self.assertEqual(mapping["properties"]["position"]["type"], "integer")
self.assertEqual(mapping["properties"]["text_ru"]["analyzer"], "russian")
self.assertEqual(mapping["properties"]["text_ky"]["analyzer"], "icu_analyzer")
if __name__ == "__main__":
unittest.main()

View 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()

View File

@@ -0,0 +1,3 @@
FROM opensearchproject/opensearch:3.7.0
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu

View File

@@ -0,0 +1,21 @@
services:
opensearch:
build: .
container_name: akyldash-opensearch
environment:
discovery.type: single-node
bootstrap.memory_lock: "true"
DISABLE_SECURITY_PLUGIN: "true"
OPENSEARCH_JAVA_OPTS: -Xms8g -Xmx8g
mem_limit: 12g
ports:
- 127.0.0.1:9200:9200
ulimits:
memlock:
soft: -1
hard: -1
nofile:
soft: 65536
hard: 65536
volumes:
- ../../data/opensearch-node:/usr/share/opensearch/data

View File

@@ -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.2.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -1,19 +1,19 @@
# Статус проекта
Последняя проверка: 2026-08-10
Последняя проверка: 2026-08-14
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.2.2`
- Backend: `0.5.2`
- Frontend: не создан
## Краткий итог
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
Ближайшая цель — выполнить полный проход нормализатора, проверить отчёт ошибок
и качество контрольной выборки RU/KY, затем подготовить индекс OpenSearch.
Ближайшая цель — оценить полный локальный индекс на 50100 запросах RU/KY и
настроить ранжирование до начала разработки поискового API.
## Уже сделано
@@ -74,6 +74,9 @@
- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
- Полный проход завершён: 209 958 документов нормализованы без ошибок.
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
### Развёртывание
@@ -132,6 +135,40 @@
## История изменений статуса
### 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 и безопасное продолжение прерванной загрузки
существующего индекса.
- Добавлены retry/backoff для временных HTTP-сбоев и подробные ошибки Bulk API.
- Ошибки чтения JSON теперь содержат точный путь и повторяются при временном сбое.
- Версия backend обновлена до `0.4.1`.
### 2026-08-13
- Добавлен локальный одноузловой OpenSearch с `analysis-icu` без Dashboards.
- Добавлена прямая потоковая загрузка корпуса пакетами до 25 МБ.
- Версия backend обновлена до `0.4.0`.
### 2026-08-13
- Подтверждена полная успешная нормализация 209 958 загруженных документов.
- Единственный отсутствующий документ `6` повторно не отдан API Минюста.
- Добавлены mapping фрагментов и потоковый экспорт для OpenSearch Bulk API.
- Версия backend обновлена до `0.3.0`.
### 2026-08-12
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
@@ -197,4 +234,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · 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

@@ -98,10 +98,10 @@ MVP не входят.
Интерфейс не должен предполагать наличие обоих языков. На момент полного
скачивания архива распределение следующее:
- только русский язык — 29 432 документа;
- только кыргызский язык — 98 797 документов;
- оба языка — 80 901 документ;
- нет HTML-текста — 681 документ.
- только русский язык — 29 433 документа;
- только кыргызский язык — 98 905 документов;
- оба языка — 80 930 документов;
- нет HTML-текста — 690 документов.
## Рекомендуемая основа frontend
@@ -258,4 +258,4 @@ runtime-зависимостями frontend. Регистрация в стор
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · 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.2.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан

View File

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

View File

@@ -180,4 +180,4 @@
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.2.2 · 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.2.2 · Frontend — не создан
Акылдаш · Telegram-бот v0.2.2 · Backend v0.5.2 · Frontend — не создан