Compare commits
88 Commits
feature/ex
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb5edf3ae2 | |||
| 000cf467df | |||
| f8f98dfe46 | |||
| 5f44981bd3 | |||
| a1b4787930 | |||
| b412315ac6 | |||
| 7e07f3ab8d | |||
| 70c70fb7dd | |||
| e18d477852 | |||
| 062aaa0074 | |||
| 470745fce3 | |||
| b2274fee6f | |||
| abd244fefb | |||
| 8302c6b782 | |||
| bbf8e7a9c8 | |||
| bf0b6ff070 | |||
| b91bc98611 | |||
| 4210c2493d | |||
| 711c9e525e | |||
| 808ac6c792 | |||
| 73eb938c03 | |||
| cccae446ae | |||
| 24b3a2d422 | |||
| f472a17897 | |||
| 182b87fff2 | |||
| 407dd2dc09 | |||
| a594aec912 | |||
| c5f651bdae | |||
| f4cd453088 | |||
| 57e4645a11 | |||
| 9b28616cf4 | |||
| e9da5a6ccc | |||
| cb58e91d6b | |||
| 39896c74de | |||
| f3f564ab08 | |||
| ccbb2f0944 | |||
| d468c869c0 | |||
| 47e03bbc4d | |||
| eafc06f0bc | |||
| 9dcbcad9aa | |||
| 028062bd14 | |||
| bb69ea54b7 | |||
| 0f309f34fa | |||
| 2ef33c5cc3 | |||
| 0ef6ad4d20 | |||
| bc3951449a | |||
| 9b629d93e3 | |||
| 0bef90debd | |||
| 73ff287b20 | |||
| 8f47dbb4f0 | |||
| 1ad2e4b256 | |||
| 61681cde73 | |||
| 9781e93eaf | |||
| 601c7f085f | |||
| 69d4090f6d | |||
| 85672dc041 | |||
| 0f6f12e0e9 | |||
| 7a656cfc1a | |||
| 72de364f29 | |||
| 74873451ab | |||
| 2faac7b74e | |||
| e3f08426d5 | |||
| dc5399de0a | |||
| 8f0f0e3fbf | |||
| 9ef8b099df | |||
| 9318474a54 | |||
| d701f714e1 | |||
| 575f4fa3af | |||
| e6c4848a7f | |||
| 0a72d0d242 | |||
| d4ee8fac08 | |||
| 2d31e02753 | |||
| 8d4725231e | |||
| 424cc16497 | |||
| 38739543f4 | |||
| 3a4450fcfe | |||
| dddb07f393 | |||
| ac5ed95f3a | |||
| 30065925e4 | |||
| 752bd38c9b | |||
| 07381ad70f | |||
| 35cae8bcbe | |||
| efa55fe74b | |||
| 0eedcfcfbd | |||
| 1d398ebb00 | |||
| 9cf6a7e940 | |||
| b8b9ec75b3 | |||
| c94131effc |
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
*.json
|
||||
!backend/
|
||||
!backend/search/
|
||||
!backend/search/*.json
|
||||
*.xlsx
|
||||
*.pdf
|
||||
*.jpg
|
||||
data/
|
||||
docs/
|
||||
tools/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,5 +11,8 @@ __pycache__/
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Local bot archive
|
||||
# Local application archives
|
||||
data/
|
||||
|
||||
# Local runtime logs
|
||||
logs/
|
||||
|
||||
40
CHANGELOG.md
Normal file
40
CHANGELOG.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# История изменений
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
|
||||
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
|
||||
бланки удалены.
|
||||
- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса.
|
||||
- Добавлен production deployment baseline для Search API и OpenSearch с
|
||||
постоянными хранилищами и healthcheck.
|
||||
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
|
||||
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
|
||||
оценка релевантности 0–3, комментарии и SQLite-экспорт подписанных снимков.
|
||||
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
|
||||
оценки поисковой выдачи.
|
||||
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
|
||||
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
|
||||
пагинация и проверка актуальных редакций в локальном OpenSearch.
|
||||
- Добавлен фильтр Search API v1 по укрупнённой юридической силе. Полная
|
||||
иерархия GeneralClassifiers экспортирована в отдельный черновой каталог;
|
||||
тематическая навигация остаётся вне API v1 до ручного утверждения рубрик и
|
||||
подписей.
|
||||
- Добавлено безопасное переключение alias на новую версию поискового индекса
|
||||
после полной загрузки; checkpoint защищает возобновление загрузки от смены
|
||||
alias.
|
||||
- Добавлен 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 кыргызских запросов с инструкцией по ручной разметке.
|
||||
77
README.md
77
README.md
@@ -1,46 +1,61 @@
|
||||
# Акылдаш — бот-секретарь
|
||||
# Акылдаш
|
||||
|
||||
Версия: `0.2.1`
|
||||
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
|
||||
собирать правовые источники на законных основаниях, сохранять их происхождение
|
||||
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
|
||||
через API и пользовательский интерфейс со ссылками на первоисточники.
|
||||
|
||||
Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и
|
||||
выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются.
|
||||
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
|
||||
|
||||
## Запуск
|
||||
## Текущее состояние
|
||||
|
||||
Требуется Python 3.11 или новее.
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
|
||||
`0.9.0`: добавлена фильтрация поиска по юридической силе.
|
||||
|
||||
```bash
|
||||
export TELEGRAM_BOT_TOKEN='...'
|
||||
export TELEGRAM_CHAT_ID='-1004242041275'
|
||||
export TELEGRAM_OWNER_ID='87262245'
|
||||
export TELEGRAM_REPORT_THREAD_ID='37'
|
||||
export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8'
|
||||
python3 bot.py
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.9.0` | фильтр поиска по юридической силе и черновик тематического каталога |
|
||||
| Frontend | — | ещё не создан |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
## Структура репозитория
|
||||
|
||||
```text
|
||||
docs/ структурированная документация проекта
|
||||
decisions/ принятые архитектурные и продуктовые решения
|
||||
operations/ состояние проекта и рабочие процессы
|
||||
product/ назначение, границы и развитие продукта
|
||||
team/ материалы для команды
|
||||
tools/
|
||||
telegram-bot/ бот рабочего Telegram-пространства
|
||||
backend/
|
||||
ingestion/ получение и обновление правовых источников
|
||||
normalization/ воспроизводимая нормализация исходного архива
|
||||
```
|
||||
|
||||
Доступные команды: `/help`, `/status`, `/export`.
|
||||
Экспорт доступен только пользователю с Telegram ID из `TELEGRAM_OWNER_ID`.
|
||||
Первый `/export` выгружает всю сохранённую тему, последующие — сообщения после
|
||||
предыдущей успешно созданной отсечки.
|
||||
Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме
|
||||
остаётся отсечка со ссылкой и общим хэштегом отчёта.
|
||||
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
|
||||
создаваться с первой реальной задачей в соответствующей области. Это позволит
|
||||
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
|
||||
|
||||
База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других
|
||||
групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому
|
||||
метаданные вложений остаются доступными для последующего скачивания.
|
||||
Начать знакомство с проектом: [документация](docs/README.md) и
|
||||
[обзор продукта](docs/product/project-overview.md).
|
||||
|
||||
## Проверка
|
||||
## Конфиденциальные материалы
|
||||
|
||||
Секреты, персональные данные, договоры и материалы по правовой защите проекта
|
||||
не должны храниться в этом репозитории. Для них нужен отдельный закрытый
|
||||
репозиторий или защищённое хранилище с минимально необходимыми правами доступа,
|
||||
журналированием и резервным копированием. Здесь допустимы только несекретные
|
||||
правила и ссылки на такие материалы без раскрытия их содержания.
|
||||
|
||||
## Проверка Telegram-бота
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v
|
||||
python3 -m unittest discover -s tools/telegram-bot -v
|
||||
```
|
||||
|
||||
## Synology
|
||||
|
||||
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` с политикой
|
||||
перезапуска `unless-stopped`. Постоянные данные находятся в
|
||||
`/volume1/docker/akyldash/data`.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш v0.2.1
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
|
||||
243
backend/README.md
Normal file
243
backend/README.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.9.0`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
`ingestion/minjust_cbd.py`: его можно запускать как CLI сейчас и импортировать
|
||||
из будущего планировщика backend без запуска отдельного процесса.
|
||||
|
||||
## Пробная загрузка
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
python3 backend/ingestion/minjust_cbd.py --limit 10
|
||||
```
|
||||
|
||||
В интерактивном терминале отображаются процент, количество документов,
|
||||
ошибки, скорость и примерное оставшееся время.
|
||||
|
||||
Полная загрузка выполняется без `--limit`. По умолчанию архив сохраняется в
|
||||
`data/minjust-cbd`, который исключён из Git. Повторный запуск пропускает уже
|
||||
загруженные документы; `--refresh` принудительно проверяет их заново.
|
||||
Если временный идентификатор списка API истечёт или запрос страницы исчерпает
|
||||
повторы во время многодневной загрузки, скрипт пересоздаст список на текущей
|
||||
странице и продолжит автоматически.
|
||||
|
||||
В версии `0.1.0` обновление существующих документов выполняется полной проверкой
|
||||
через `--refresh`. Инкрементальную проверку по `lastmod` из sitemap следует
|
||||
добавить вместе с backend-планировщиком, когда будет определена частота запуска.
|
||||
|
||||
## Защита API
|
||||
|
||||
Запросы выполняются последовательно, по умолчанию не чаще одного в секунду.
|
||||
Timeout одного ответа — 60 секунд, число попыток — 5, задержка между повторами
|
||||
растёт экспоненциально. При HTTP 429 учитывается заголовок `Retry-After`.
|
||||
Лимит действует на один процесс, поэтому одновременно следует запускать только
|
||||
один экземпляр загрузчика.
|
||||
|
||||
Более осторожный режим:
|
||||
|
||||
```bash
|
||||
python3 backend/ingestion/minjust_cbd.py --requests-per-second 0.5
|
||||
```
|
||||
|
||||
## Вызов из будущего backend
|
||||
|
||||
```python
|
||||
from ingestion.minjust_cbd import sync_archive
|
||||
|
||||
result = sync_archive(output_path)
|
||||
```
|
||||
|
||||
Планировщик, очередь задач и PostgreSQL пока не добавлены: модуль не зависит от
|
||||
выбора будущего backend-фреймворка.
|
||||
|
||||
## Нормализация архива
|
||||
|
||||
Нормализатор читает исходный архив без изменений и создаёт отдельный набор
|
||||
данных для будущих поиска, API и RAG:
|
||||
|
||||
```bash
|
||||
python3 backend/normalization/minjust_cbd.py --limit 10
|
||||
python3 backend/normalization/minjust_cbd.py
|
||||
```
|
||||
|
||||
По умолчанию источник читается из `data/minjust-cbd`, а результат записывается
|
||||
в `data/minjust-normalized`. Пути можно изменить параметрами `--input` и
|
||||
`--output`; `--refresh` принудительно обрабатывает неизменившиеся документы,
|
||||
`--log-level` задаёт уровень журнала.
|
||||
Каталоги `--input` и `--output` не должны совпадать, содержать друг друга или
|
||||
пересекаться через разрешённые абсолютные пути.
|
||||
|
||||
```text
|
||||
data/minjust-normalized/
|
||||
manifest.sqlite3
|
||||
documents/<code>/document.json
|
||||
documents/<code>/editions/<edition>/edition.json
|
||||
documents/<code>/editions/<edition>/<lang>/content.html
|
||||
documents/<code>/editions/<edition>/<lang>/content.txt
|
||||
documents/<code>/editions/<edition>/<lang>/fragments.json
|
||||
```
|
||||
|
||||
`content.html` содержит только разрешённую безопасную разметку, `content.txt` —
|
||||
текст для поиска, а `fragments.json` — адресуемые блоки с детерминированными ID
|
||||
и SHA-256. Манифест пропускает документы с неизменившимися исходниками и
|
||||
повторяет документы, обработка которых завершилась ошибкой.
|
||||
|
||||
Первая версия не выполняет OCR, перевод, юридические выводы о редакциях,
|
||||
сопоставление фрагментов, загрузку в PostgreSQL/OpenSearch и построение RAG.
|
||||
Внешние и встроенные `data:`-изображения из HTML удаляются; сведения и пути к
|
||||
локальным изображениям исходного архива сохраняются в `edition.json`.
|
||||
|
||||
## Проверка
|
||||
|
||||
```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, чтобы удалённые
|
||||
фрагменты не оставались в поиске.
|
||||
|
||||
Чтобы переключить alias атомарно только после успешной полной загрузки,
|
||||
передайте `--alias`:
|
||||
|
||||
```bash
|
||||
python3 backend/search/minjust_opensearch.py \
|
||||
--url http://127.0.0.1:9200 \
|
||||
--index akyldash-fragments-v2 \
|
||||
--alias akyldash-fragments-current
|
||||
```
|
||||
|
||||
После каждого принятого 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`,
|
||||
`--alias` и SHA-256 нормализованного manifest. При `--resume` передавайте то же
|
||||
значение `--alias`; старый checkpoint без alias можно продолжить только без
|
||||
него. Resume отклоняется при любом несовпадении:
|
||||
для обновлённого корпуса или пересозданного индекса нужно создать новый
|
||||
версионный индекс, проверить его и переключить alias. Это не оставляет
|
||||
удалённые trailing-фрагменты старых документов.
|
||||
|
||||
## HTTP API v1
|
||||
|
||||
Запустите публичный read-only API поверх текущего alias и нормализованного
|
||||
корпуса:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.api
|
||||
```
|
||||
|
||||
Он публикует OpenAPI в `GET /openapi.json` и поддерживает `GET /search`,
|
||||
`/search/filters`, `/documents/{code}`, `/documents/{code}/editions` и
|
||||
`/documents/{code}/editions/{edition}`. Значения фильтров возвращаются со
|
||||
стабильным кодом справочника v1 и подписями RU/KY; применяйте `code` как
|
||||
параметр поиска. API не подменяет отсутствующий язык документа.
|
||||
|
||||
## Локальный 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 и
|
||||
разбора ошибок выдачи.
|
||||
|
||||
## Внутренняя лаборатория релевантности
|
||||
|
||||
Запустите Search API на localhost и откройте `http://127.0.0.1:8080/review`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.api \\
|
||||
--reviews-db data/search-reviews.sqlite3
|
||||
```
|
||||
|
||||
Лаборатория показывает фактический порядок выдачи OpenSearch, позволяет открыть
|
||||
текст редакции, поставить результату оценку от 0 до 3 и сохранить снимок с
|
||||
комментариями. Оценки сохраняются в SQLite, экспорт доступен через
|
||||
`GET /search-reviews/export`. Интерфейс предназначен только для локальной сети
|
||||
или защищённого reverse proxy; не публикуйте его напрямую в интернет.
|
||||
|
||||
Для проверки текущей выдачи без будущего HTTP API используйте CLI:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend python3 -m search.query "как открыть ОсОО" --language ru
|
||||
PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --language ky
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.9.0 · Frontend — не создан
|
||||
373
backend/ingestion/minjust_cbd.py
Normal file
373
backend/ingestion/minjust_cbd.py
Normal file
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download and archive documents from the Kyrgyz Republic Ministry of Justice."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
APP_VERSION = "0.9.0"
|
||||
API_BASE_URL = "https://cbd.minjust.gov.kg/api/v1/OpenData/"
|
||||
LANGUAGES = {"Rus": "ru", "Kyr": "ky"}
|
||||
IMAGE_LANGUAGES = {"Russian": "ru", "Kyrgyz": "ky"}
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CbdClient:
|
||||
def __init__(
|
||||
self,
|
||||
requests_per_second: float = 1,
|
||||
timeout: int = 60,
|
||||
retries: int = 5,
|
||||
) -> None:
|
||||
if requests_per_second <= 0:
|
||||
raise ValueError("requests_per_second must be greater than zero")
|
||||
if timeout <= 0 or retries <= 0:
|
||||
raise ValueError("timeout and retries must be greater than zero")
|
||||
# ponytail: per-process limit; add a distributed lock before multiple workers.
|
||||
self.interval = 1 / requests_per_second
|
||||
self.timeout = timeout
|
||||
self.retries = retries
|
||||
self.last_request = 0.0
|
||||
self.total_documents = 0
|
||||
|
||||
def request_json(self, method: str, parameters: Iterable[tuple[str, object]] = ()):
|
||||
query = urllib.parse.urlencode(list(parameters), doseq=True)
|
||||
extension = "" if method == "CheckAvailable" else ".json"
|
||||
url = f"{API_BASE_URL}{method}{extension}" + (f"?{query}" if query else "")
|
||||
for attempt in range(self.retries):
|
||||
retry_delay = 2**attempt
|
||||
wait = self.interval - (time.monotonic() - self.last_request)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": f"Akyldash/{APP_VERSION} (+https://cbd.minjust.gov.kg)",
|
||||
},
|
||||
)
|
||||
try:
|
||||
self.last_request = time.monotonic()
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
body = response.read()
|
||||
return json.loads(body) if body else None
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code != 429 and error.code < 500:
|
||||
raise
|
||||
if error.code == 429:
|
||||
try:
|
||||
retry_delay = max(
|
||||
retry_delay, float(error.headers.get("Retry-After", 0))
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
except (TimeoutError, urllib.error.URLError):
|
||||
pass
|
||||
if attempt + 1 < self.retries:
|
||||
time.sleep(retry_delay)
|
||||
raise RuntimeError(f"Ministry of Justice API request failed: {url}")
|
||||
|
||||
def check_available(self) -> None:
|
||||
self.request_json("CheckAvailable")
|
||||
|
||||
def document_codes(self, limit: int | None = None, page_size: int = 1000):
|
||||
def query_page(page_number: int):
|
||||
return self.request_json(
|
||||
"GetDocumentListByQuery",
|
||||
(
|
||||
("Property", "Code"),
|
||||
("PageSize", page_size),
|
||||
("PageNumber", page_number),
|
||||
),
|
||||
)
|
||||
|
||||
first = query_page(1)
|
||||
yielded = 0
|
||||
page = first
|
||||
page_number = 1
|
||||
self.total_documents = min(first["TotalCount"], limit or first["TotalCount"])
|
||||
while True:
|
||||
for document in page["Documents"]:
|
||||
yield document["Code"]
|
||||
yielded += 1
|
||||
if limit is not None and yielded >= limit:
|
||||
return
|
||||
if yielded >= page["TotalCount"]:
|
||||
return
|
||||
page_number += 1
|
||||
try:
|
||||
page = self.request_json(
|
||||
"GetDocumentListById",
|
||||
(
|
||||
("DocumentListId", first["Id"]),
|
||||
("Property", "Code"),
|
||||
("PageSize", page_size),
|
||||
("PageNumber", page_number),
|
||||
),
|
||||
)
|
||||
except (urllib.error.HTTPError, RuntimeError) as error:
|
||||
if isinstance(error, urllib.error.HTTPError) and error.code != 404:
|
||||
raise
|
||||
first = page = query_page(page_number)
|
||||
self.total_documents = min(
|
||||
first["TotalCount"], limit or first["TotalCount"]
|
||||
)
|
||||
|
||||
def document(self, code: int) -> dict:
|
||||
return self.request_json(
|
||||
"GetDocument",
|
||||
(
|
||||
("Code", code),
|
||||
("Editions.Select", "all"),
|
||||
("Editions.Data", "all"),
|
||||
("Editions.Images.Select", "all"),
|
||||
("Editions.Images.Data", "all"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncResult:
|
||||
discovered: int = 0
|
||||
downloaded: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
seconds = max(0, round(seconds))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
|
||||
def progress_line(
|
||||
result: SyncResult, total: int, elapsed: float, width: int = 24
|
||||
) -> str:
|
||||
processed = result.discovered
|
||||
fraction = processed / total if total else 0
|
||||
filled = min(width, round(width * fraction))
|
||||
rate = processed / elapsed if elapsed > 0 else 0
|
||||
eta = (total - processed) / rate if rate else 0
|
||||
return (
|
||||
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
|
||||
f"{processed}/{total} downloaded={result.downloaded} "
|
||||
f"skipped={result.skipped} failed={result.failed} "
|
||||
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
|
||||
)
|
||||
|
||||
|
||||
def terminal_progress() -> Callable[[SyncResult, int], None]:
|
||||
started = time.monotonic()
|
||||
|
||||
def update(result: SyncResult, total: int) -> None:
|
||||
line = progress_line(result, total, time.monotonic() - started)
|
||||
print(
|
||||
f"\r{line}",
|
||||
end="\n" if result.discovered >= total else "",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def connect_manifest(path: Path) -> sqlite3.Connection:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
code INTEGER PRIMARY KEY,
|
||||
source_sha256 TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS errors (
|
||||
code INTEGER PRIMARY KEY,
|
||||
message TEXT NOT NULL,
|
||||
failed_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
||||
temporary.write(content)
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, path)
|
||||
|
||||
|
||||
def json_bytes(value: object) -> bytes:
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode()
|
||||
|
||||
|
||||
def archive_document(root: Path, document: dict) -> str:
|
||||
code = int(document["Code"])
|
||||
source = json.dumps(document, ensure_ascii=False, sort_keys=True).encode()
|
||||
source_sha256 = hashlib.sha256(source).hexdigest()
|
||||
directory = root / "documents" / str(code)
|
||||
editions = document.get("Editions") or []
|
||||
metadata = {key: value for key, value in document.items() if key != "Editions"}
|
||||
atomic_write(directory / "metadata.json", json_bytes(metadata))
|
||||
|
||||
for edition in editions:
|
||||
edition_directory = directory / "editions" / str(int(edition["Code"]))
|
||||
edition_metadata = {key: value for key, value in edition.items() if key != "Data"}
|
||||
edition_metadata["Images"] = [
|
||||
{key: value for key, value in image.items() if key != "Data"}
|
||||
for image in edition.get("Images") or []
|
||||
]
|
||||
atomic_write(edition_directory / "metadata.json", json_bytes(edition_metadata))
|
||||
|
||||
for source_language, filename in LANGUAGES.items():
|
||||
html = (edition.get("Data") or {}).get(source_language)
|
||||
if html:
|
||||
atomic_write(edition_directory / f"{filename}.html", html.encode())
|
||||
|
||||
for image in edition.get("Images") or []:
|
||||
if not image.get("Data"):
|
||||
continue
|
||||
language = IMAGE_LANGUAGES.get(image.get("Lang"), "unknown")
|
||||
filename = Path(str(image.get("Name") or "image").replace("\\", "/")).name
|
||||
if filename in {"", ".", ".."}:
|
||||
raise ValueError(f"Invalid image filename in document {code}")
|
||||
atomic_write(
|
||||
edition_directory / "images" / language / filename,
|
||||
base64.b64decode(image["Data"], validate=True),
|
||||
)
|
||||
return source_sha256
|
||||
|
||||
|
||||
def sync_archive(
|
||||
output: Path,
|
||||
client: CbdClient | None = None,
|
||||
limit: int | None = None,
|
||||
refresh: bool = False,
|
||||
progress: Callable[[SyncResult, int], None] | None = None,
|
||||
) -> SyncResult:
|
||||
client = client or CbdClient()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
connection = connect_manifest(output / "manifest.sqlite3")
|
||||
known = {row[0] for row in connection.execute("SELECT code FROM documents")}
|
||||
discovered = downloaded = skipped = failed = 0
|
||||
|
||||
client.check_available()
|
||||
for code in client.document_codes(limit):
|
||||
discovered += 1
|
||||
# ponytail: refresh scans all documents; use sitemap lastmod when scheduled
|
||||
# update checks become frequent enough for the extra mapping logic to pay off.
|
||||
if code in known and not refresh:
|
||||
skipped += 1
|
||||
if progress:
|
||||
progress(
|
||||
SyncResult(discovered, downloaded, skipped, failed),
|
||||
client.total_documents,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
document = client.document(code)
|
||||
checksum = archive_document(output, document)
|
||||
fetched_at = datetime.now(timezone.utc).isoformat()
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO documents (code, source_sha256, fetched_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
source_sha256 = excluded.source_sha256,
|
||||
fetched_at = excluded.fetched_at
|
||||
""",
|
||||
(int(document["Code"]), checksum, fetched_at),
|
||||
)
|
||||
connection.execute("DELETE FROM errors WHERE code = ?", (code,))
|
||||
downloaded += 1
|
||||
except Exception as error: # Continue the long-running archive after one bad record.
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO errors (code, message, failed_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
message = excluded.message,
|
||||
failed_at = excluded.failed_at
|
||||
""",
|
||||
(code, str(error), datetime.now(timezone.utc).isoformat()),
|
||||
)
|
||||
failed += 1
|
||||
if progress:
|
||||
progress(
|
||||
SyncResult(discovered, downloaded, skipped, failed),
|
||||
client.total_documents,
|
||||
)
|
||||
if not progress and discovered % 100 == 0:
|
||||
LOGGER.info(
|
||||
"discovered=%s downloaded=%s skipped=%s failed=%s",
|
||||
discovered,
|
||||
downloaded,
|
||||
skipped,
|
||||
failed,
|
||||
)
|
||||
connection.close()
|
||||
return SyncResult(discovered, downloaded, skipped, failed)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, default=Path("data/minjust-cbd"))
|
||||
parser.add_argument("--limit", type=int, help="download only the first N documents")
|
||||
parser.add_argument("--refresh", action="store_true", help="redownload known documents")
|
||||
parser.add_argument("--requests-per-second", type=float, default=1)
|
||||
parser.add_argument("--timeout", type=int, default=60)
|
||||
parser.add_argument("--retries", type=int, default=5)
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_args()
|
||||
if arguments.limit is not None and arguments.limit <= 0:
|
||||
raise SystemExit("--limit must be greater than zero")
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
result = sync_archive(
|
||||
arguments.output,
|
||||
CbdClient(
|
||||
requests_per_second=arguments.requests_per_second,
|
||||
timeout=arguments.timeout,
|
||||
retries=arguments.retries,
|
||||
),
|
||||
arguments.limit,
|
||||
arguments.refresh,
|
||||
terminal_progress() if sys.stderr.isatty() else None,
|
||||
)
|
||||
print(
|
||||
f"discovered={result.discovered} downloaded={result.downloaded} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
729
backend/normalization/minjust_cbd.py
Normal file
729
backend/normalization/minjust_cbd.py
Normal file
@@ -0,0 +1,729 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Normalize the local Ministry of Justice CBD archive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
APP_VERSION = "0.9.0"
|
||||
SCHEMA_VERSION = "1"
|
||||
NORMALIZER_VERSION = "1.0.0"
|
||||
LANGUAGES = ("ru", "ky")
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_TAGS = {
|
||||
"a", "b", "blockquote", "br", "div", "em", "h1", "h2", "h3", "h4",
|
||||
"h5", "h6", "i", "img", "li", "ol", "p", "pre", "span", "strong",
|
||||
"sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "tr",
|
||||
"u", "ul",
|
||||
}
|
||||
VOID_TAGS = {"br", "img"}
|
||||
DROP_CONTENT_TAGS = {"applet", "iframe", "noscript", "object", "script", "style", "svg"}
|
||||
DROP_ELEMENT_TAGS = {"link", "meta"}
|
||||
BLOCK_TAGS = {"blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "pre", "td", "th"}
|
||||
AUTO_CLOSE = {
|
||||
"li": {"li"},
|
||||
"p": {"blockquote", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "ol", "p", "pre", "table", "ul"},
|
||||
"td": {"td", "th"},
|
||||
"th": {"td", "th"},
|
||||
"tr": {"tr"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizeResult:
|
||||
discovered: int = 0
|
||||
normalized: int = 0
|
||||
skipped: int = 0
|
||||
failed: int = 0
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def atomic_write(path: Path, content: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as temporary:
|
||||
temporary.write(content)
|
||||
temporary_path = Path(temporary.name)
|
||||
os.replace(temporary_path, path)
|
||||
|
||||
|
||||
def json_bytes(value: object) -> bytes:
|
||||
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def sha256_bytes(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def source_inventory(document_directory: Path, input_root: Path) -> tuple[list[dict], str]:
|
||||
files = []
|
||||
combined = hashlib.sha256()
|
||||
for path in sorted(item for item in document_directory.rglob("*") if item.is_file()):
|
||||
relative = path.relative_to(input_root).as_posix()
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
checksum = digest.hexdigest()
|
||||
files.append({"path": relative, "sha256": checksum})
|
||||
combined.update(relative.encode("utf-8"))
|
||||
combined.update(b"\0")
|
||||
combined.update(checksum.encode("ascii"))
|
||||
combined.update(b"\0")
|
||||
return files, combined.hexdigest()
|
||||
|
||||
|
||||
def clean_value(value):
|
||||
if isinstance(value, str):
|
||||
normalized = unicodedata.normalize("NFC", value)
|
||||
return normalized if normalized.strip() else None
|
||||
if isinstance(value, dict):
|
||||
return {key: clean_value(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [clean_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def bilingual(value) -> dict[str, object]:
|
||||
value = value if isinstance(value, dict) else {}
|
||||
return {"ru": clean_value(value.get("Rus")), "ky": clean_value(value.get("Kyr"))}
|
||||
|
||||
|
||||
def hierarchy_paths(items: object, child_key: str) -> list[dict]:
|
||||
paths: list[dict] = []
|
||||
|
||||
def visit(nodes: object, ancestors: dict[str, list[str]]) -> None:
|
||||
for node in nodes if isinstance(nodes, list) else []:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
names = bilingual(node.get("Name"))
|
||||
current = {language: list(ancestors[language]) for language in LANGUAGES}
|
||||
for language in LANGUAGES:
|
||||
name = names[language]
|
||||
if name:
|
||||
current[language].append(str(name))
|
||||
children = node.get(child_key)
|
||||
if children:
|
||||
visit(children, current)
|
||||
else:
|
||||
paths.append(current)
|
||||
|
||||
visit(items, {"ru": [], "ky": []})
|
||||
return paths
|
||||
|
||||
|
||||
class SafeHtmlParser(HTMLParser):
|
||||
def __init__(self, edition_directory: Path) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.edition_directory = edition_directory.resolve()
|
||||
self.parts: list[str] = []
|
||||
self.stack: list[str] = []
|
||||
self.drop_depth = 0
|
||||
self.removed_elements = 0
|
||||
self.removed_attributes = 0
|
||||
self.removed_images = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
tag = tag.lower()
|
||||
if self.drop_depth:
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
self.drop_depth += 1
|
||||
return
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
self.drop_depth = 1
|
||||
self.removed_elements += 1
|
||||
return
|
||||
if tag in DROP_ELEMENT_TAGS:
|
||||
self.removed_elements += 1
|
||||
return
|
||||
if tag not in ALLOWED_TAGS:
|
||||
self.removed_elements += 1
|
||||
return
|
||||
for open_tag, closing_tags in AUTO_CLOSE.items():
|
||||
if tag in closing_tags and open_tag in self.stack:
|
||||
self._close(open_tag)
|
||||
safe_attrs = self._attributes(tag, attrs)
|
||||
if tag == "img" and not any(name == "src" for name, _ in safe_attrs):
|
||||
self.removed_images += 1
|
||||
return
|
||||
rendered = "".join(
|
||||
f' {name}="{html.escape(value, quote=True)}"' for name, value in safe_attrs
|
||||
)
|
||||
self.parts.append(f"<{tag}{rendered}>")
|
||||
if tag not in VOID_TAGS:
|
||||
self.stack.append(tag)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
if tag.lower() not in VOID_TAGS:
|
||||
self.handle_endtag(tag)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
tag = tag.lower()
|
||||
if self.drop_depth:
|
||||
if tag in DROP_CONTENT_TAGS:
|
||||
self.drop_depth -= 1
|
||||
return
|
||||
if tag in self.stack:
|
||||
self._close(tag)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if not self.drop_depth:
|
||||
self.parts.append(html.escape(unicodedata.normalize("NFC", data), quote=False))
|
||||
|
||||
def close(self) -> None:
|
||||
super().close()
|
||||
while self.stack:
|
||||
self.parts.append(f"</{self.stack.pop()}>")
|
||||
|
||||
def _close(self, tag: str) -> None:
|
||||
while self.stack:
|
||||
current = self.stack.pop()
|
||||
self.parts.append(f"</{current}>")
|
||||
if current == tag:
|
||||
break
|
||||
|
||||
def _attributes(self, tag: str, attrs: list[tuple[str, str | None]]) -> list[tuple[str, str]]:
|
||||
allowed = {"title"}
|
||||
if tag == "a":
|
||||
allowed |= {"href"}
|
||||
elif tag == "img":
|
||||
allowed |= {"alt", "src"}
|
||||
elif tag in {"td", "th"}:
|
||||
allowed |= {"colspan", "rowspan"}
|
||||
safe = []
|
||||
for raw_name, raw_value in attrs:
|
||||
name = raw_name.lower()
|
||||
value = unicodedata.normalize("NFC", raw_value or "")
|
||||
if name not in allowed:
|
||||
self.removed_attributes += 1
|
||||
continue
|
||||
if name == "href" and not safe_link(value):
|
||||
self.removed_attributes += 1
|
||||
continue
|
||||
if name == "src" and not self.safe_image(value):
|
||||
self.removed_attributes += 1
|
||||
continue
|
||||
if name in {"colspan", "rowspan"} and not value.isdigit():
|
||||
self.removed_attributes += 1
|
||||
continue
|
||||
safe.append((name, value))
|
||||
if tag == "a" and any(name == "href" and urlsplit(value).scheme in {"http", "https"} for name, value in safe):
|
||||
safe.append(("rel", "noopener noreferrer"))
|
||||
return safe
|
||||
|
||||
def safe_image(self, value: str) -> bool:
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
except ValueError:
|
||||
return False
|
||||
if parsed.scheme or parsed.netloc or not parsed.path or parsed.path.startswith(("/", "\\")):
|
||||
return False
|
||||
candidate = (self.edition_directory / parsed.path.replace("\\", "/")).resolve()
|
||||
try:
|
||||
candidate.relative_to(self.edition_directory)
|
||||
except ValueError:
|
||||
return False
|
||||
return candidate.is_file()
|
||||
|
||||
|
||||
def safe_link(value: str) -> bool:
|
||||
value = value.strip()
|
||||
if not value or value.startswith(("//", "\\\\")):
|
||||
return False
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return parsed.scheme.lower() in {"", "http", "https", "mailto"} and not (
|
||||
not parsed.scheme and parsed.netloc
|
||||
)
|
||||
|
||||
|
||||
def sanitize_html(source: str, edition_directory: Path) -> tuple[str, dict]:
|
||||
parser = SafeHtmlParser(edition_directory)
|
||||
parser.feed(source)
|
||||
parser.close()
|
||||
return unicodedata.normalize("NFC", "".join(parser.parts)), {
|
||||
"removed_elements": parser.removed_elements,
|
||||
"removed_attributes": parser.removed_attributes,
|
||||
"removed_images": parser.removed_images,
|
||||
}
|
||||
|
||||
|
||||
class TextBlockParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.blocks: list[tuple[str, str]] = []
|
||||
self.active_tag: str | None = None
|
||||
self.active: list[str] = []
|
||||
self.loose: list[str] = []
|
||||
|
||||
def handle_starttag(self, tag: str, attrs) -> None:
|
||||
if tag in BLOCK_TAGS:
|
||||
self._flush_active()
|
||||
self._flush_loose()
|
||||
self.active_tag = tag
|
||||
elif tag == "br":
|
||||
(self.active if self.active_tag else self.loose).append("\n")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == self.active_tag:
|
||||
self._flush_active()
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
(self.active if self.active_tag else self.loose).append(data)
|
||||
|
||||
def close(self) -> None:
|
||||
super().close()
|
||||
self._flush_active()
|
||||
self._flush_loose()
|
||||
|
||||
def _flush_active(self) -> None:
|
||||
if self.active_tag:
|
||||
text = clean_text("".join(self.active))
|
||||
if text:
|
||||
self.blocks.append((self.active_tag, text))
|
||||
self.active_tag = None
|
||||
self.active = []
|
||||
|
||||
def _flush_loose(self) -> None:
|
||||
text = clean_text("".join(self.loose))
|
||||
if text:
|
||||
self.blocks.append(("p", text))
|
||||
self.loose = []
|
||||
|
||||
|
||||
def clean_text(value: str) -> str:
|
||||
lines = []
|
||||
for line in unicodedata.normalize("NFC", value).replace("\xa0", " ").splitlines():
|
||||
line = re.sub(r"[ \t\f\v]+", " ", line).strip()
|
||||
if line:
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fragment_type(tag: str, text: str, language: str) -> str:
|
||||
lowered = text.casefold()
|
||||
article_words = ("статья", "ст.") if language == "ru" else ("берене", "статья")
|
||||
if any(re.match(rf"^{re.escape(word)}\s*\d", lowered) for word in article_words):
|
||||
return "article"
|
||||
if re.match(r"^\d+(?:\.\d+)*[.)]?\s+", text):
|
||||
return "point"
|
||||
if tag.startswith("h"):
|
||||
return "heading"
|
||||
return {"li": "list_item", "td": "table_cell", "th": "table_header"}.get(tag, "paragraph")
|
||||
|
||||
|
||||
def extract_text_and_fragments(
|
||||
sanitized: str,
|
||||
document_code: str,
|
||||
edition_code: str,
|
||||
language: str,
|
||||
source_path: str,
|
||||
source_sha256: str,
|
||||
) -> tuple[str, list[dict]]:
|
||||
parser = TextBlockParser()
|
||||
parser.feed(sanitized)
|
||||
parser.close()
|
||||
fragments = []
|
||||
for position, (tag, text) in enumerate(parser.blocks, 1):
|
||||
fragments.append(
|
||||
{
|
||||
"id": f"document:{document_code}:edition:{edition_code}:lang:{language}:fragment:{position}",
|
||||
"document_code": document_code,
|
||||
"edition_code": edition_code,
|
||||
"language": language,
|
||||
"position": position,
|
||||
"type": fragment_type(tag, text, language),
|
||||
"text": text,
|
||||
"text_sha256": sha256_bytes(text.encode("utf-8")),
|
||||
"source_path": source_path,
|
||||
"source_sha256": source_sha256,
|
||||
}
|
||||
)
|
||||
return "\n\n".join(fragment["text"] for fragment in fragments), fragments
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"Expected JSON object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def edition_summary(edition_directory: Path, input_root: Path) -> dict:
|
||||
metadata = load_json(edition_directory / "metadata.json")
|
||||
languages = [language for language in LANGUAGES if (edition_directory / f"{language}.html").is_file()]
|
||||
return {
|
||||
"source_code": str(metadata.get("Code", edition_directory.name)),
|
||||
"name": bilingual(metadata.get("Name")),
|
||||
"source_type": clean_value(metadata.get("Type")),
|
||||
"available_languages": languages,
|
||||
"source_path": edition_directory.relative_to(input_root).as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def normalize_document(
|
||||
document_directory: Path,
|
||||
input_root: Path,
|
||||
destination: Path,
|
||||
files: list[dict] | None = None,
|
||||
source_checksum: str | None = None,
|
||||
) -> None:
|
||||
metadata_path = document_directory / "metadata.json"
|
||||
metadata = load_json(metadata_path)
|
||||
document_code = str(metadata.get("Code", document_directory.name))
|
||||
if document_code != document_directory.name:
|
||||
raise ValueError(f"Document code mismatch in {metadata_path}")
|
||||
if files is None or source_checksum is None:
|
||||
files, source_checksum = source_inventory(document_directory, input_root)
|
||||
file_checksums = {item["path"]: item["sha256"] for item in files}
|
||||
edition_root = document_directory / "editions"
|
||||
edition_directories = sorted(
|
||||
(path for path in edition_root.iterdir() if path.is_dir()),
|
||||
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
|
||||
) if edition_root.is_dir() else []
|
||||
summaries = [edition_summary(path, input_root) for path in edition_directories]
|
||||
available_languages = [language for language in LANGUAGES if any(language in item["available_languages"] for item in summaries)]
|
||||
normalized_metadata = clean_value(metadata)
|
||||
document = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"source_code": document_code,
|
||||
"class": bilingual(metadata.get("Class")),
|
||||
"type": bilingual(metadata.get("Type")),
|
||||
"title": bilingual(metadata.get("Title")),
|
||||
"name": bilingual(metadata.get("Name")),
|
||||
"status": bilingual(metadata.get("Status")),
|
||||
"number": clean_value(metadata.get("Number")),
|
||||
"dates": {key: value for key, value in normalized_metadata.items() if key.startswith("Date")},
|
||||
"registration_number": clean_value(metadata.get("NumberRegistration")),
|
||||
"publication_number": clean_value(metadata.get("NumberPublication")),
|
||||
"is_public_in_cdb": metadata.get("IsPublicInCdb"),
|
||||
"is_public_in_register": metadata.get("IsPublicInRegister"),
|
||||
"authorities": normalized_metadata.get("Authorities") or [],
|
||||
"authority_paths": hierarchy_paths(metadata.get("Authorities"), "Authorities"),
|
||||
"source_publications": normalized_metadata.get("SourcePublications") or [],
|
||||
"source_publication_paths": hierarchy_paths(metadata.get("SourcePublications"), "SourcePublications"),
|
||||
"keywords": normalized_metadata.get("Keywords") or [],
|
||||
"keyword_paths": hierarchy_paths(metadata.get("Keywords"), "Keywords"),
|
||||
"general_classifiers": normalized_metadata.get("GeneralClassifiers") or [],
|
||||
"general_classifier_paths": hierarchy_paths(metadata.get("GeneralClassifiers"), "GeneralClassifiers"),
|
||||
"references": normalized_metadata.get("References") or [],
|
||||
"source_metadata": normalized_metadata,
|
||||
"available_languages": available_languages,
|
||||
"editions": summaries,
|
||||
"source": {
|
||||
"path": document_directory.relative_to(input_root).as_posix(),
|
||||
"files": files,
|
||||
"sha256": source_checksum,
|
||||
},
|
||||
"normalizer": {"version": NORMALIZER_VERSION, "processed_at": utc_now()},
|
||||
}
|
||||
atomic_write(destination / "document.json", json_bytes(document))
|
||||
|
||||
for edition_directory, summary in zip(edition_directories, summaries):
|
||||
edition_code = summary["source_code"]
|
||||
edition_metadata = load_json(edition_directory / "metadata.json")
|
||||
edition_destination = destination / "editions" / edition_directory.name
|
||||
image_records = []
|
||||
for image in edition_metadata.get("Images") or []:
|
||||
language = {"Russian": "ru", "Kyrgyz": "ky"}.get(image.get("Lang"), "unknown")
|
||||
name = Path(str(image.get("Name") or "").replace("\\", "/")).name
|
||||
source_path = edition_directory / "images" / language / name
|
||||
relative = source_path.relative_to(input_root).as_posix()
|
||||
image_records.append(
|
||||
{
|
||||
"language": language,
|
||||
"name": clean_value(image.get("Name")),
|
||||
"source_path": relative if source_path.is_file() else None,
|
||||
"source_sha256": file_checksums.get(relative),
|
||||
"source_metadata": clean_value(image),
|
||||
}
|
||||
)
|
||||
quality = {"has_html": bool(summary["available_languages"]), "languages": {}}
|
||||
for language in summary["available_languages"]:
|
||||
html_path = edition_directory / f"{language}.html"
|
||||
relative = html_path.relative_to(input_root).as_posix()
|
||||
raw = html_path.read_text(encoding="utf-8")
|
||||
sanitized, sanitizer_quality = sanitize_html(raw, edition_directory)
|
||||
text, fragments = extract_text_and_fragments(
|
||||
sanitized, document_code, edition_code, language, relative, file_checksums[relative]
|
||||
)
|
||||
language_destination = edition_destination / language
|
||||
atomic_write(language_destination / "content.html", sanitized.encode("utf-8"))
|
||||
atomic_write(language_destination / "content.txt", (text + ("\n" if text else "")).encode("utf-8"))
|
||||
atomic_write(language_destination / "fragments.json", json_bytes(fragments))
|
||||
quality["languages"][language] = {
|
||||
**sanitizer_quality,
|
||||
"empty_text": not bool(text),
|
||||
"fragment_count": len(fragments),
|
||||
}
|
||||
edition = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"source_code": edition_code,
|
||||
"name": summary["name"],
|
||||
"source_type": summary["source_type"],
|
||||
"available_languages": summary["available_languages"],
|
||||
"images": image_records,
|
||||
"source_metadata": clean_value(edition_metadata),
|
||||
"source": {
|
||||
"path": summary["source_path"],
|
||||
"files": [item for item in files if item["path"].startswith(summary["source_path"] + "/")],
|
||||
},
|
||||
"quality": quality,
|
||||
}
|
||||
atomic_write(edition_destination / "edition.json", json_bytes(edition))
|
||||
|
||||
|
||||
def connect_manifest(path: Path) -> sqlite3.Connection:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
code TEXT PRIMARY KEY,
|
||||
source_sha256 TEXT,
|
||||
schema_version TEXT NOT NULL,
|
||||
normalizer_version TEXT NOT NULL,
|
||||
processed_at TEXT,
|
||||
state TEXT NOT NULL,
|
||||
error TEXT,
|
||||
failed_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(documents)")}
|
||||
if "failed_at" not in columns:
|
||||
connection.execute("ALTER TABLE documents ADD COLUMN failed_at TEXT")
|
||||
return connection
|
||||
|
||||
|
||||
def publish_directory(staged: Path, target: Path) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
backup = target.parent / f".{target.name}.previous"
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
if target.exists():
|
||||
os.replace(target, backup)
|
||||
try:
|
||||
os.replace(staged, target)
|
||||
except Exception:
|
||||
if backup.exists():
|
||||
os.replace(backup, target)
|
||||
raise
|
||||
if backup.exists():
|
||||
shutil.rmtree(backup)
|
||||
|
||||
|
||||
def recover_directory(target: Path) -> None:
|
||||
backup = target.parent / f".{target.name}.previous"
|
||||
if not backup.exists():
|
||||
return
|
||||
if target.exists():
|
||||
shutil.rmtree(backup)
|
||||
else:
|
||||
os.replace(backup, target)
|
||||
|
||||
|
||||
def validate_roots(input_root: Path, output: Path) -> None:
|
||||
source = input_root.resolve()
|
||||
destination = output.resolve()
|
||||
if source == destination or source.is_relative_to(destination) or destination.is_relative_to(source):
|
||||
raise ValueError("--input and --output must not overlap")
|
||||
|
||||
|
||||
def normalize_archive(
|
||||
input_root: Path = Path("data/minjust-cbd"),
|
||||
output: Path = Path("data/minjust-normalized"),
|
||||
limit: int | None = None,
|
||||
refresh: bool = False,
|
||||
progress: Callable[[NormalizeResult, int], None] | None = None,
|
||||
) -> NormalizeResult:
|
||||
validate_roots(input_root, output)
|
||||
document_root = input_root / "documents"
|
||||
if not document_root.is_dir():
|
||||
raise FileNotFoundError(f"Document directory not found: {document_root}")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
connection = connect_manifest(output / "manifest.sqlite3")
|
||||
known = {
|
||||
row[0]: (row[1], row[2], row[3], row[4])
|
||||
for row in connection.execute(
|
||||
"SELECT code, source_sha256, schema_version, normalizer_version, state FROM documents"
|
||||
)
|
||||
}
|
||||
directories = sorted(
|
||||
(path for path in document_root.iterdir() if path.is_dir()),
|
||||
key=lambda path: (not path.name.isdigit(), int(path.name) if path.name.isdigit() else path.name),
|
||||
)
|
||||
if limit is not None:
|
||||
directories = directories[:limit]
|
||||
total = len(directories)
|
||||
discovered = normalized = skipped = failed = 0
|
||||
staging_root = output / ".staging"
|
||||
staging_root.mkdir(exist_ok=True)
|
||||
try:
|
||||
for source_directory in directories:
|
||||
discovered += 1
|
||||
code = source_directory.name
|
||||
target = output / "documents" / code
|
||||
checksum = None
|
||||
try:
|
||||
recover_directory(target)
|
||||
files, checksum = source_inventory(source_directory, input_root)
|
||||
if target.is_dir() and not refresh and known.get(code) == (
|
||||
checksum, SCHEMA_VERSION, NORMALIZER_VERSION, "success"
|
||||
):
|
||||
skipped += 1
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(dir=staging_root) as temporary:
|
||||
staged = Path(temporary) / code
|
||||
normalize_document(source_directory, input_root, staged, files, checksum)
|
||||
publish_directory(staged, target)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO documents (
|
||||
code, source_sha256, schema_version,
|
||||
normalizer_version, processed_at, state, error,
|
||||
failed_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'success', NULL, NULL)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
source_sha256=excluded.source_sha256,
|
||||
schema_version=excluded.schema_version,
|
||||
normalizer_version=excluded.normalizer_version,
|
||||
processed_at=excluded.processed_at,
|
||||
state='success', error=NULL, failed_at=NULL
|
||||
""",
|
||||
(code, checksum, SCHEMA_VERSION, NORMALIZER_VERSION, utc_now()),
|
||||
)
|
||||
normalized += 1
|
||||
except Exception as error: # Keep a corpus run alive after one malformed record.
|
||||
LOGGER.exception("Failed to normalize document %s", code)
|
||||
with connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO documents (
|
||||
code, source_sha256, schema_version,
|
||||
normalizer_version, processed_at, state, error,
|
||||
failed_at
|
||||
) VALUES (?, ?, ?, ?, NULL, 'error', ?, ?)
|
||||
ON CONFLICT(code) DO UPDATE SET
|
||||
source_sha256=excluded.source_sha256,
|
||||
schema_version=excluded.schema_version,
|
||||
normalizer_version=excluded.normalizer_version,
|
||||
state='error', error=excluded.error,
|
||||
failed_at=excluded.failed_at
|
||||
""",
|
||||
(
|
||||
code,
|
||||
checksum,
|
||||
SCHEMA_VERSION,
|
||||
NORMALIZER_VERSION,
|
||||
str(error),
|
||||
utc_now(),
|
||||
),
|
||||
)
|
||||
failed += 1
|
||||
result = NormalizeResult(discovered, normalized, skipped, failed)
|
||||
if progress:
|
||||
progress(result, total)
|
||||
elif discovered % 100 == 0:
|
||||
LOGGER.info("discovered=%s normalized=%s skipped=%s failed=%s", discovered, normalized, skipped, failed)
|
||||
finally:
|
||||
connection.close()
|
||||
try:
|
||||
staging_root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
return NormalizeResult(discovered, normalized, skipped, failed)
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
seconds = max(0, round(seconds))
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
|
||||
def progress_line(result: NormalizeResult, total: int, elapsed: float, width: int = 24) -> str:
|
||||
fraction = result.discovered / total if total else 0
|
||||
filled = min(width, round(width * fraction))
|
||||
rate = result.discovered / elapsed if elapsed > 0 else 0
|
||||
eta = (total - result.discovered) / rate if rate else 0
|
||||
return (
|
||||
f"[{'#' * filled}{'-' * (width - filled)}] {fraction:6.2%} "
|
||||
f"{result.discovered}/{total} normalized={result.normalized} "
|
||||
f"skipped={result.skipped} failed={result.failed} "
|
||||
f"rate={rate:.2f}/s ETA={format_duration(eta)}"
|
||||
)
|
||||
|
||||
|
||||
def terminal_progress() -> Callable[[NormalizeResult, int], None]:
|
||||
started = time.monotonic()
|
||||
|
||||
def update(result: NormalizeResult, total: int) -> None:
|
||||
print(
|
||||
f"\r{progress_line(result, total, time.monotonic() - started)}",
|
||||
end="\n" if result.discovered >= total else "",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--input", type=Path, default=Path("data/minjust-cbd"))
|
||||
parser.add_argument("--output", type=Path, default=Path("data/minjust-normalized"))
|
||||
parser.add_argument("--limit", type=int, help="normalize only the first N documents")
|
||||
parser.add_argument("--refresh", action="store_true", help="renormalize unchanged documents")
|
||||
parser.add_argument("--log-level", choices=("DEBUG", "INFO", "WARNING", "ERROR"), default="INFO")
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_args()
|
||||
if arguments.limit is not None and arguments.limit <= 0:
|
||||
raise SystemExit("--limit must be greater than zero")
|
||||
logging.basicConfig(level=arguments.log_level, format="%(asctime)s %(levelname)s %(message)s")
|
||||
result = normalize_archive(
|
||||
arguments.input,
|
||||
arguments.output,
|
||||
arguments.limit,
|
||||
arguments.refresh,
|
||||
terminal_progress() if sys.stderr.isatty() else None,
|
||||
)
|
||||
print(
|
||||
f"discovered={result.discovered} normalized={result.normalized} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash Backend v{APP_VERSION} · Frontend — not created"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
108
backend/search/RELEVANCE_ANNOTATION.md
Normal file
108
backend/search/RELEVANCE_ANNOTATION.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Инструкция по разметке 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. Юрист устанавливает релевантные акты по содержанию и реквизитам документов.
|
||||
Используйте ЦБД Минюста как авторитетный источник для проверки текста,
|
||||
статуса и редакции акта. Порядок выдачи ЦБД не является объектом оценки и не
|
||||
переносится в эталон.
|
||||
3. Запишите уникальные `document_code` всех документов, которые прямо отвечают
|
||||
на запрос. Спорные документы передайте на независимую проверку.
|
||||
4. Зафиксируйте согласованный набор до просмотра результатов OpenSearch и
|
||||
сохраните его отдельно от рабочих данных.
|
||||
5. Проверьте запрос во внутренней лаборатории (`/review`): оцените фактические
|
||||
результаты OpenSearch в исходном порядке, сохраните снимок и комментарии.
|
||||
Лаборатория проверяет качество нашей поисковой системы, а не ЦБД Минюста.
|
||||
|
||||
Пример структуры (код условный):
|
||||
|
||||
```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.
|
||||
367
backend/search/api.py
Normal file
367
backend/search/api.py
Normal file
@@ -0,0 +1,367 @@
|
||||
"""Minimal HTTP API for the normalized legal-document corpus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
from search.minjust_opensearch import APP_VERSION, request_json
|
||||
from search.catalog import CATALOGS, labels
|
||||
from search.reviews import ReviewSnapshots, ReviewStore
|
||||
|
||||
|
||||
API_VERSION = "v1"
|
||||
SEARCH_ALGORITHM_VERSION = "search-1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
CODE = re.compile(r"^[0-9]+$")
|
||||
MAX_PAGE_SIZE = 100
|
||||
MAX_RESULT_WINDOW = 10_000
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
def __init__(self, status: int, message: str):
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
def parse_positive(value: str | None, name: str, default: int, maximum: int) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
parsed = int(value)
|
||||
except ValueError as error:
|
||||
raise ApiError(400, f"{name} must be an integer") from error
|
||||
if not 1 <= parsed <= maximum:
|
||||
raise ApiError(400, f"{name} must be between 1 and {maximum}")
|
||||
return parsed
|
||||
|
||||
|
||||
def one(query: dict[str, list[str]], name: str) -> str | None:
|
||||
values = query.get(name, [])
|
||||
if len(values) > 1:
|
||||
raise ApiError(400, f"{name} must be specified once")
|
||||
return values[0] if values else None
|
||||
|
||||
|
||||
def date(value: str | None, name: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
datetime.date.fromisoformat(value)
|
||||
except ValueError as error:
|
||||
raise ApiError(400, f"{name} must be an ISO date") from error
|
||||
return value
|
||||
|
||||
|
||||
def openapi() -> dict:
|
||||
responses = {"200": {"description": "Successful response"}, "400": {"description": "Invalid request"}, "404": {"description": "Not found"}, "502": {"description": "Search backend unavailable"}}
|
||||
return {
|
||||
"openapi": "3.0.3",
|
||||
"info": {"title": "Akyldash Search API", "version": API_VERSION},
|
||||
"paths": {
|
||||
"/search": {"get": {"responses": responses, "parameters": [
|
||||
{"name": "q", "in": "query", "required": True, "schema": {"type": "string"}},
|
||||
{"name": "language", "in": "query", "schema": {"type": "string", "enum": ["ru", "ky"]}},
|
||||
{"name": "page", "in": "query", "schema": {"type": "integer", "minimum": 1}},
|
||||
{"name": "page_size", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": MAX_PAGE_SIZE}},
|
||||
{"name": "document_type", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "status", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "authority", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "legal_force", "in": "query", "schema": {"type": "string"}},
|
||||
{"name": "date_from", "in": "query", "schema": {"type": "string", "format": "date"}},
|
||||
{"name": "date_to", "in": "query", "schema": {"type": "string", "format": "date"}},
|
||||
{"name": "sort", "in": "query", "schema": {"type": "string", "enum": ["relevance", "date"]}},
|
||||
]}},
|
||||
"/search/filters": {"get": {"responses": responses}},
|
||||
"/search-reviews": {"post": {"responses": {"201": {"description": "Review saved"}, "400": {"description": "Invalid review"}}}},
|
||||
"/search-reviews/export": {"get": {"responses": responses}},
|
||||
"/review": {"get": {"responses": {"200": {"description": "Review interface"}}}},
|
||||
"/documents/{code}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
"/documents/{code}/editions": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
"/documents/{code}/editions/{edition}": {"get": {"responses": responses, "parameters": [{"name": "code", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}, {"name": "edition", "in": "path", "required": True, "schema": {"type": "string", "pattern": "^[0-9]+$"}}]}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class Api:
|
||||
def __init__(self, base_url: str, index: str, data_root: Path, reviews_db: Path | str = ":memory:", review_secret: bytes | None = None):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.index = index
|
||||
self.data_root = data_root
|
||||
self.review_store = ReviewStore(reviews_db)
|
||||
self.review_snapshots = ReviewSnapshots(review_secret)
|
||||
|
||||
def search_url(self, suffix: str) -> str:
|
||||
return f"{self.base_url}/{urllib.parse.quote(self.index, safe='')}/{suffix}"
|
||||
|
||||
def query_opensearch(self, body: dict) -> dict:
|
||||
try:
|
||||
return request_json(self.search_url("_search"), "POST", json.dumps(body, ensure_ascii=False).encode(), "application/json")
|
||||
except RuntimeError as error:
|
||||
raise ApiError(502, "search backend is unavailable") from error
|
||||
|
||||
def search(self, query: dict[str, list[str]]) -> dict:
|
||||
text = one(query, "q")
|
||||
if not text or not text.strip():
|
||||
raise ApiError(400, "q is required")
|
||||
if len(text) > 500:
|
||||
raise ApiError(400, "q must not exceed 500 characters")
|
||||
language = one(query, "language") or "ru"
|
||||
if language not in LANGUAGES:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
page = parse_positive(one(query, "page"), "page", 1, 1_000_000)
|
||||
page_size = parse_positive(one(query, "page_size"), "page_size", 20, MAX_PAGE_SIZE)
|
||||
if page * page_size >= MAX_RESULT_WINDOW:
|
||||
raise ApiError(400, f"page and page_size must stay within {MAX_RESULT_WINDOW} results")
|
||||
sort = one(query, "sort") or "relevance"
|
||||
if sort not in {"relevance", "date"}:
|
||||
raise ApiError(400, "sort must be relevance or date")
|
||||
filters: list[dict] = [{"term": {"language": language}}, {"term": {"is_current_edition": True}}]
|
||||
fields = {"document_type": "document_type_code", "status": "status_code", "authority": "authority_codes", "legal_force": "legal_force_code"}
|
||||
for parameter, field in fields.items():
|
||||
value = one(query, parameter)
|
||||
if value:
|
||||
if value not in CATALOGS[parameter]:
|
||||
raise ApiError(400, f"{parameter} must be a catalog code")
|
||||
filters.append({"term": {field: value}})
|
||||
date_from, date_to = date(one(query, "date_from"), "date_from"), date(one(query, "date_to"), "date_to")
|
||||
if date_from and date_to and date_from > date_to:
|
||||
raise ApiError(400, "date_from must not be later than date_to")
|
||||
if date_from or date_to:
|
||||
date_range = {key: value for key, value in (("gte", date_from), ("lte", date_to)) if value}
|
||||
filters.append({"range": {"date_adopted": date_range}})
|
||||
body = {
|
||||
"from": (page - 1) * page_size,
|
||||
"size": page_size + 1,
|
||||
"_source": ["document_code", "edition_code", "document_name_ru", "document_name_ky", "document_type_ru", "document_type_ky", "status_ru", "status_ky", "legal_force_code", "date_adopted", "number"],
|
||||
"query": {"bool": {"filter": filters, "must": {"multi_match": {"query": text, "fields": [f"document_name_{language}", f"text_{language}"], "type": "cross_fields"}}}},
|
||||
"collapse": {"field": "document_code"},
|
||||
"highlight": {"fields": {f"text_{language}": {"number_of_fragments": 1}}},
|
||||
}
|
||||
if sort == "date":
|
||||
body["sort"] = [{"date_adopted": "desc"}, {"_score": "desc"}]
|
||||
response = self.query_opensearch(body)
|
||||
try:
|
||||
hits = response["hits"]["hits"]
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ApiError(502, "search backend returned an incomplete response") from error
|
||||
results = [self.search_hit(hit, language) for hit in hits[:page_size]]
|
||||
snapshot_results = [{"rank": rank, **result} for rank, result in enumerate(results, 1)]
|
||||
concrete_indexes = {hit.get("_index") for hit in hits[:page_size] if hit.get("_index")}
|
||||
index_name = next(iter(concrete_indexes)) if len(concrete_indexes) == 1 else self.index
|
||||
return {"api_version": API_VERSION, "query": text, "language": language, "page": page, "page_size": page_size, "has_next": len(hits) > page_size, "results": results, "review_token": self.review_snapshots.create(text, language, index_name, snapshot_results, SEARCH_ALGORITHM_VERSION)}
|
||||
|
||||
@staticmethod
|
||||
def search_hit(hit: dict, language: str) -> dict:
|
||||
source = hit.get("_source")
|
||||
if not isinstance(source, dict) or not source.get("document_code"):
|
||||
raise ApiError(502, "search backend returned an incomplete result")
|
||||
highlight = hit.get("highlight", {}).get(f"text_{language}", [])
|
||||
legal_force = source.get("legal_force_code")
|
||||
return {"code": source["document_code"], "edition": source.get("edition_code"), "name": source.get(f"document_name_{language}"), "type": source.get(f"document_type_{language}"), "status": source.get(f"status_{language}"), "legal_force": labels("legal_force", legal_force)[language] if legal_force else None, "date_adopted": source.get("date_adopted"), "number": source.get("number"), "snippet": highlight[0] if highlight else None}
|
||||
|
||||
def filters(self, query: dict[str, list[str]]) -> dict:
|
||||
language = one(query, "language") or "ru"
|
||||
if language not in LANGUAGES:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
fields = {"document_types": ("document_type", "document_type_code"), "statuses": ("status", "status_code"), "authorities": ("authority", "authority_codes"), "legal_forces": ("legal_force", "legal_force_code")}
|
||||
body = {"size": 0, "query": {"term": {"is_current_edition": True}}, "aggs": {name: {"terms": {"field": pair[1], "size": 1000}, "aggs": {"documents": {"cardinality": {"field": "document_code", "precision_threshold": 40000}}}} for name, pair in fields.items()}}
|
||||
response = self.query_opensearch(body)
|
||||
try:
|
||||
aggregations = response["aggregations"]
|
||||
values = {
|
||||
name: [{"code": item["key"], "labels": labels(pair[0], item["key"]), "count": item["documents"]["value"]} for item in aggregations[name]["buckets"]]
|
||||
for name, pair in fields.items()
|
||||
}
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ApiError(502, "search backend returned incomplete filters") from error
|
||||
return {"api_version": API_VERSION, "language": language, **values}
|
||||
|
||||
def directory(self, code: str) -> Path:
|
||||
if not CODE.fullmatch(code):
|
||||
raise ApiError(404, "document not found")
|
||||
path = self.data_root / "documents" / code
|
||||
if not path.is_dir():
|
||||
raise ApiError(404, "document not found")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def read_json(path: Path, message: str) -> dict:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ApiError(500, message) from error
|
||||
if not isinstance(value, dict):
|
||||
raise ApiError(500, message)
|
||||
return value
|
||||
|
||||
def document(self, code: str) -> dict:
|
||||
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
|
||||
editions = document.get("editions")
|
||||
if not isinstance(editions, list):
|
||||
raise ApiError(500, "document data is unavailable")
|
||||
return {"api_version": API_VERSION, "document": document, "current_edition": editions[-1] if editions else None}
|
||||
|
||||
def editions(self, code: str) -> dict:
|
||||
document = self.read_json(self.directory(code) / "document.json", "document data is unavailable")
|
||||
return {"api_version": API_VERSION, "code": code, "available_languages": document.get("available_languages", []), "editions": document.get("editions", [])}
|
||||
|
||||
def edition(self, code: str, edition: str, query: dict[str, list[str]]) -> dict:
|
||||
if not CODE.fullmatch(edition):
|
||||
raise ApiError(404, "edition not found")
|
||||
directory = self.directory(code) / "editions" / edition
|
||||
if not directory.is_dir():
|
||||
raise ApiError(404, "edition not found")
|
||||
metadata = self.read_json(directory / "edition.json", "edition data is unavailable")
|
||||
language = one(query, "language")
|
||||
if language is not None and language not in LANGUAGES:
|
||||
raise ApiError(400, "language must be ru or ky")
|
||||
languages = [language] if language else metadata.get("available_languages", [])
|
||||
content = {}
|
||||
for item in languages:
|
||||
if item not in metadata.get("available_languages", []):
|
||||
continue
|
||||
try:
|
||||
content[item] = {"html": (directory / item / "content.html").read_text(encoding="utf-8"), "text": (directory / item / "content.txt").read_text(encoding="utf-8")}
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise ApiError(500, "edition content is unavailable") from error
|
||||
if language and language not in content:
|
||||
raise ApiError(404, "edition language not found")
|
||||
return {"api_version": API_VERSION, "edition": metadata, "content": content}
|
||||
|
||||
def save_review(self, body: dict) -> dict:
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(400, "request body must be an object")
|
||||
try:
|
||||
snapshot = self.review_snapshots.verify(body["review_token"])
|
||||
reviewer = body["reviewer"].strip()
|
||||
overall_comment = body.get("overall_comment", "").strip()
|
||||
submitted = body["results"]
|
||||
except (KeyError, AttributeError, TypeError, ValueError) as error:
|
||||
raise ApiError(400, "review_token, reviewer and results are required") from error
|
||||
if not reviewer or len(reviewer) > 120:
|
||||
raise ApiError(400, "reviewer must be between 1 and 120 characters")
|
||||
if len(overall_comment) > 4000:
|
||||
raise ApiError(400, "overall_comment is too long")
|
||||
if not isinstance(submitted, list):
|
||||
raise ApiError(400, "results must be an array")
|
||||
by_rank = {item["rank"]: item for item in snapshot["results"]}
|
||||
if len(submitted) != len(by_rank) or {item.get("rank") for item in submitted if isinstance(item, dict)} != set(by_rank):
|
||||
raise ApiError(400, "all search results must be reviewed exactly once")
|
||||
results = []
|
||||
for item in submitted:
|
||||
if not isinstance(item, dict) or not isinstance(item.get("rank"), int) or item["rank"] not in by_rank:
|
||||
raise ApiError(400, "review result rank is invalid")
|
||||
source = by_rank[item["rank"]]
|
||||
if item.get("code") != source["code"]:
|
||||
raise ApiError(400, "review result document does not match the search snapshot")
|
||||
rating = item.get("rating")
|
||||
if rating is not None and (isinstance(rating, bool) or not isinstance(rating, int) or not 0 <= rating <= 3):
|
||||
raise ApiError(400, "rating must be an integer from 0 to 3")
|
||||
comment = item.get("comment", "")
|
||||
if not isinstance(comment, str) or len(comment) > 4000:
|
||||
raise ApiError(400, "result comment is too long")
|
||||
results.append({**source, "rating": rating, "comment": comment.strip()})
|
||||
if not results:
|
||||
raise ApiError(400, "at least one result must be reviewed")
|
||||
review = {"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), "reviewer": reviewer, "query": snapshot["query"], "language": snapshot["language"], "index_name": snapshot["index_name"], "algorithm_version": snapshot["algorithm_version"], "top_result_code": snapshot["results"][0]["code"] if snapshot["results"] else None, "results": results, "overall_comment": overall_comment}
|
||||
review_id = self.review_store.save(review)
|
||||
return {"api_version": API_VERSION, "id": review_id, "created_at": review["created_at"]}
|
||||
|
||||
@staticmethod
|
||||
def review_page() -> str:
|
||||
try:
|
||||
return Path(__file__).with_name("review.html").read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise ApiError(500, "review interface is unavailable") from error
|
||||
|
||||
def handle(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict]:
|
||||
parsed = urllib.parse.urlsplit(path)
|
||||
query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
|
||||
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
|
||||
if method == "POST" and parts == ["search-reviews"]:
|
||||
return 201, self.save_review(body)
|
||||
if method == "GET" and parts == ["search-reviews", "export"]:
|
||||
return 200, {"api_version": API_VERSION, "reviews": self.review_store.export()}
|
||||
if method != "GET":
|
||||
raise ApiError(405, "method not allowed")
|
||||
if parts == ["openapi.json"]:
|
||||
return 200, openapi()
|
||||
if parts == ["search"]:
|
||||
return 200, self.search(query)
|
||||
if parts == ["search", "filters"]:
|
||||
return 200, self.filters(query)
|
||||
if len(parts) == 2 and parts[0] == "documents":
|
||||
return 200, self.document(parts[1])
|
||||
if len(parts) == 3 and parts[:1] == ["documents"] and parts[2] == "editions":
|
||||
return 200, self.editions(parts[1])
|
||||
if len(parts) == 4 and parts[:1] == ["documents"] and parts[2] == "editions":
|
||||
return 200, self.edition(parts[1], parts[3], query)
|
||||
raise ApiError(404, "endpoint not found")
|
||||
|
||||
|
||||
def handler(api: Api):
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
def respond(self, method: str):
|
||||
try:
|
||||
if method == "GET" and urllib.parse.urlsplit(self.path).path == "/review":
|
||||
body = api.review_page().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
body = None
|
||||
if method == "POST":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if length > 1_000_000:
|
||||
raise ApiError(413, "request body is too large")
|
||||
body = json.loads(self.rfile.read(length) or b"{}")
|
||||
status, payload = api.handle(method, self.path, body)
|
||||
except json.JSONDecodeError:
|
||||
status, payload = 400, {"api_version": API_VERSION, "error": "request body must be valid JSON"}
|
||||
except ApiError as error:
|
||||
status, payload = error.status, {"api_version": API_VERSION, "error": error.message}
|
||||
body = json.dumps(payload, ensure_ascii=False).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self.respond("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self.respond("POST")
|
||||
|
||||
def log_message(self, format: str, *args):
|
||||
return
|
||||
|
||||
return RequestHandler
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--url", default="http://127.0.0.1:9200")
|
||||
parser.add_argument("--index", default="akyldash-fragments-current")
|
||||
parser.add_argument("--data", type=Path, default=Path("data/minjust-normalized"))
|
||||
parser.add_argument("--reviews-db", type=Path, default=Path("data/search-reviews.sqlite3"))
|
||||
parser.add_argument("--review-secret", default=None)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8080)
|
||||
parser.add_argument("--version", action="version", version=APP_VERSION)
|
||||
arguments = parser.parse_args()
|
||||
secret = arguments.review_secret.encode() if arguments.review_secret else None
|
||||
ThreadingHTTPServer((arguments.host, arguments.port), handler(Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret))).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
68
backend/search/catalog.py
Normal file
68
backend/search/catalog.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Immutable v1 search catalogs."""
|
||||
|
||||
DOCUMENT_TYPES = {
|
||||
"constitution": ("Конституция", "Конституция"), "constitutional_law": ("Конституционный Закон", "Конституциалык Мыйзам"), "code": ("Кодекс", "Кодекс"), "law": ("Закон", "Мыйзам"), "decree": ("Указ", "Жарлык"), "resolution": ("Постановление", "Токтом"), "order": ("Распоряжение", "Распоряжение"), "instruction": ("Инструкция", "Инструкция"), "rules": ("Правила", "Правила"), "procedure": ("Порядок", "Порядок"), "provision": ("Положение", "Жобо"), "regulation": ("Регламент", "Регламент"), "charter": ("Устав", "Жобо (Устав)"), "program": ("Программа", "Программа"), "plan": ("План", "План"), "strategy": ("Стратегия", "Стратегия"), "concept": ("Концепция", "Концепция"), "doctrine": ("Доктрина", "Доктрина"), "agreement": ("Соглашение", "Соглашение"), "declaration": ("Декларация", "Декларация"), "registry": ("Реестр", "Реестр"), "norms": ("Нормативы", "Нормативы"), "model": ("Модель", "Модель"), "matrix": ("Матрица", "Матрица"), "study": ("Исследование", "Исследование"), "report": ("Доклад", "Доклад"), "principles": ("Основные принципы", "Основные принципы"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес"),
|
||||
}
|
||||
STATUSES = {"active": ("Действует", "Күчүндө"), "repealed": ("Утратил силу", "Күчүн жоготту"), "unspecified": ("Не указан", "Көрсөтүлгөн эмес")}
|
||||
AUTHORITIES = {"president": ("Президент", "Президент"), "parliament": ("Органы законодательной власти", "Мыйзам чыгаруу бийлик органдары"), "cabinet": ("Правительство и Кабинет Министров", "Өкмөт жана Министрлер Кабинети"), "ministries_and_committees": ("Министерства и государственные комитеты", "Министрликтер жана мамлекеттик комитеттер"), "administrative_agencies": ("Административные ведомства", "Административдик ведомстволор"), "national_bank": ("Национальный банк", "Улуттук банк"), "other_state_bodies": ("Иные государственные органы", "Башка мамлекеттик органдар"), "local_representative_bodies": ("Представительные органы местного самоуправления", "Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары"), "other": ("Прочие органы", "Башка органдар")}
|
||||
LEGAL_FORCE_LEVELS = {
|
||||
"constitutional": ("Конституционный уровень", "Конституциялык деңгээл"),
|
||||
"legislative": ("Законодательный уровень", "Мыйзам деңгээли"),
|
||||
"subordinate": ("Подзаконный уровень", "Мыйзам алдындагы деңгээл"),
|
||||
}
|
||||
LEGAL_FORCE_BY_DOCUMENT_TYPE = {
|
||||
"constitution": "constitutional",
|
||||
"constitutional_law": "constitutional",
|
||||
"code": "legislative",
|
||||
"law": "legislative",
|
||||
"decree": "subordinate",
|
||||
"resolution": "subordinate",
|
||||
}
|
||||
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES, "legal_force": LEGAL_FORCE_LEVELS}
|
||||
|
||||
|
||||
def labels(category: str, code: str) -> dict[str, str]:
|
||||
try:
|
||||
ru, ky = CATALOGS[category][code]
|
||||
except KeyError as error:
|
||||
raise ValueError(f"Unknown {category} catalog code: {code}") from error
|
||||
return {"ru": ru, "ky": ky}
|
||||
|
||||
|
||||
def source_code(category: str, value: dict | None) -> str:
|
||||
pair = ((value or {}).get("ru"), (value or {}).get("ky"))
|
||||
if pair == (None, None):
|
||||
return "unspecified"
|
||||
for code, expected in CATALOGS[category].items():
|
||||
if pair == expected or category == "document_type" and code == "provision" and pair == ("Положение", "Положение"):
|
||||
return code
|
||||
raise ValueError(f"Unmapped {category} catalog value: {pair!r}")
|
||||
|
||||
|
||||
def authority_codes(paths: list[dict]) -> list[str]:
|
||||
codes = set()
|
||||
for path in paths:
|
||||
text = " ".join(path.get("ru", []) + path.get("ky", [])).lower()
|
||||
if "президент" in text:
|
||||
codes.add("president")
|
||||
elif "жогорку кенеш" in text or "верховный совет" in text or "мыйзам чыгаруу" in text:
|
||||
codes.add("parliament")
|
||||
elif "кабинет министров" in text or "правительство" in text or "өкмөт" in text:
|
||||
codes.add("cabinet")
|
||||
elif "министер" in text or "мамлекеттик комитет" in text:
|
||||
codes.add("ministries_and_committees")
|
||||
elif "административ" in text:
|
||||
codes.add("administrative_agencies")
|
||||
elif "национальн" in text and "банк" in text or "улуттук банк" in text:
|
||||
codes.add("national_bank")
|
||||
elif "кенеш" in text or "кеңеш" in text or "айыл" in text or "местного самоуправления" in text:
|
||||
codes.add("local_representative_bodies")
|
||||
elif "иные государственные" in text or "башка мамлекеттик" in text:
|
||||
codes.add("other_state_bodies")
|
||||
else:
|
||||
codes.add("other")
|
||||
return sorted(codes) or ["other"]
|
||||
|
||||
|
||||
def legal_force_code(document_type: dict | None) -> str | None:
|
||||
return LEGAL_FORCE_BY_DOCUMENT_TYPE.get(source_code("document_type", document_type))
|
||||
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())
|
||||
40
backend/search/minjust-fragments-index.json
Normal file
40
backend/search/minjust-fragments-index.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"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" },
|
||||
"is_current_edition": { "type": "boolean" },
|
||||
"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" },
|
||||
"document_type_code": { "type": "keyword" },
|
||||
"legal_force_code": { "type": "keyword" },
|
||||
"status_ru": { "type": "keyword" },
|
||||
"status_ky": { "type": "keyword" },
|
||||
"status_code": { "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 },
|
||||
"authority_codes": { "type": "keyword" },
|
||||
"source_path": { "type": "keyword", "index": false },
|
||||
"source_sha256": { "type": "keyword", "index": false },
|
||||
"text_sha256": { "type": "keyword", "index": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
468
backend/search/minjust_opensearch.py
Normal file
468
backend/search/minjust_opensearch.py
Normal file
@@ -0,0 +1,468 @@
|
||||
"""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
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.9.0"
|
||||
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], current_edition: str) -> 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,
|
||||
"is_current_edition": edition_code == current_edition,
|
||||
"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"),
|
||||
"document_type_code": source_code("document_type", document.get("type")),
|
||||
"legal_force_code": legal_force_code(document.get("type")),
|
||||
"status_ru": localized(document.get("status"), "ru"),
|
||||
"status_ky": localized(document.get("status"), "ky"),
|
||||
"status_code": source_code("status", document.get("status")),
|
||||
"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"),
|
||||
"authority_codes": authority_codes(document.get("authority_paths", [])),
|
||||
"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}")
|
||||
edition_root = directory / "editions"
|
||||
editions = [path.name for path in edition_root.iterdir() if path.is_dir()] if edition_root.is_dir() else []
|
||||
if not editions:
|
||||
continue
|
||||
current_edition = max(editions, key=lambda value: (not value.isdigit(), int(value) if value.isdigit() else value))
|
||||
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), current_edition)
|
||||
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,
|
||||
alias: str | 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": 2,
|
||||
"url": url,
|
||||
"cluster_uuid": cluster_uuid,
|
||||
"index": index,
|
||||
"index_uuid": index_uuid,
|
||||
"input": str(source),
|
||||
"manifest_sha256": manifest_sha256,
|
||||
"limit": limit,
|
||||
"alias": alias,
|
||||
"last_document_code": None,
|
||||
"complete": False,
|
||||
}, None
|
||||
|
||||
state = read_json(checkpoint)
|
||||
expected = {
|
||||
"schema_version",
|
||||
"url",
|
||||
"cluster_uuid",
|
||||
"index",
|
||||
"index_uuid",
|
||||
"input",
|
||||
"manifest_sha256",
|
||||
"limit",
|
||||
"alias",
|
||||
"last_document_code",
|
||||
"complete",
|
||||
}
|
||||
legacy_expected = expected - {"alias"}
|
||||
if not isinstance(state, dict):
|
||||
raise ValueError(f"Invalid checkpoint: {checkpoint}")
|
||||
if set(state) == legacy_expected:
|
||||
if state["schema_version"] != 1 or alias is not None:
|
||||
raise ValueError(f"Legacy checkpoint does not support --alias: {checkpoint}")
|
||||
state["schema_version"] = 2
|
||||
state["alias"] = None
|
||||
elif set(state) != expected:
|
||||
raise ValueError(f"Invalid checkpoint: {checkpoint}")
|
||||
if (
|
||||
state["schema_version"] != 2
|
||||
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["alias"] != alias:
|
||||
raise ValueError(f"Checkpoint alias does not match --alias: {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"),
|
||||
alias: str | None = None,
|
||||
) -> tuple[int, int]:
|
||||
base = url.rstrip("/")
|
||||
if alias is not None and (not alias or alias == index):
|
||||
raise ValueError("--alias must differ from --index")
|
||||
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,
|
||||
alias,
|
||||
)
|
||||
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)
|
||||
if alias:
|
||||
switch_alias(base, index, alias)
|
||||
state["complete"] = True
|
||||
write_json_atomic(checkpoint, state)
|
||||
return documents, fragments
|
||||
|
||||
|
||||
def switch_alias(base: str, index: str, alias: str) -> None:
|
||||
if not alias or alias == index:
|
||||
raise ValueError("--alias must differ from --index")
|
||||
result = request_json(
|
||||
f"{base.rstrip('/')}/_aliases",
|
||||
"POST",
|
||||
json.dumps({
|
||||
"actions": [
|
||||
{"remove": {"index": "*", "alias": alias, "must_exist": False}},
|
||||
{"add": {"index": index, "alias": alias}},
|
||||
]
|
||||
}).encode(),
|
||||
"application/json",
|
||||
)
|
||||
if result.get("acknowledged") is not True:
|
||||
raise RuntimeError(f"OpenSearch did not acknowledge alias switch: {alias}")
|
||||
|
||||
|
||||
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("--alias", help="atomically point this alias at --index after a successful load")
|
||||
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.alias == "":
|
||||
raise SystemExit("--alias must not be empty")
|
||||
if arguments.alias is not None and not arguments.url:
|
||||
raise SystemExit("--alias 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,
|
||||
arguments.alias,
|
||||
)
|
||||
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
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": []}
|
||||
]
|
||||
114
backend/search/review.html
Normal file
114
backend/search/review.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Оценка поисковой выдачи · Акылдаш</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font: 16px/1.5 system-ui, sans-serif; color: #17202a; background: #f5f7fa; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
.review__skip { position: absolute; inset-block-start: 0; inset-inline-start: -10000px; padding: .5rem 1rem; background: #fff; }
|
||||
.review__skip:focus { inset-inline-start: 1rem; z-index: 2; }
|
||||
.review__header, .review__main { max-width: 1440px; margin: auto; padding-inline: 1rem; }
|
||||
.review__header { padding-block: 1.5rem 1rem; }
|
||||
.review__main { padding-block-end: 8rem; }
|
||||
.review__search { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; padding: 1rem; background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; }
|
||||
.review__field { display: grid; gap: .25rem; min-width: 12rem; flex: 1; }
|
||||
.review__field--small { flex: 0 0 7rem; min-width: 7rem; }
|
||||
input, select, textarea, button { font: inherit; }
|
||||
input, select, textarea { border: 1px solid #8d9aaa; border-radius: .4rem; padding: .6rem .7rem; background: #fff; }
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #1769aa; outline-offset: 2px; }
|
||||
button { cursor: pointer; border: 1px solid #536273; border-radius: .4rem; padding: .6rem .9rem; background: #fff; color: #17202a; }
|
||||
button:hover { background: #edf3f8; }
|
||||
.review__button--primary { background: #145a86; color: #fff; border-color: #145a86; }
|
||||
.review__button--primary:hover { background: #0e4669; }
|
||||
.review__status { min-height: 1.7rem; margin-block: .75rem; }
|
||||
.review__status--error { color: #9b1c1c; }
|
||||
.review__workspace { display: grid; grid-template-columns: minmax(22rem, 1fr) minmax(24rem, 1.1fr); gap: 1rem; align-items: start; }
|
||||
.review__results, .review__document { background: #fff; border: 1px solid #d9e0e7; border-radius: .75rem; padding: 1rem; }
|
||||
.review__results-list { display: grid; gap: 1rem; margin: 0; padding: 0; list-style: none; }
|
||||
.review__result { border-block-start: 1px solid #d9e0e7; padding-block-start: 1rem; }
|
||||
.review__result:first-child { border-block-start: 0; padding-block-start: 0; }
|
||||
.review__result-title { display: flex; gap: .5rem; align-items: baseline; width: 100%; text-align: start; font-weight: 700; border: 0; padding: 0; color: #124f78; }
|
||||
.review__rank { flex: 0 0 auto; color: #526272; font-variant-numeric: tabular-nums; }
|
||||
.review__meta, .review__snippet { margin-block: .35rem; color: #526272; }
|
||||
.review__snippet { overflow-wrap: anywhere; }
|
||||
.review__rating { display: flex; flex-wrap: wrap; gap: .45rem; margin-block: .65rem; padding: 0; border: 0; }
|
||||
.review__rating legend { width: 100%; font-weight: 600; }
|
||||
.review__rating label { min-width: 3.5rem; text-align: center; }
|
||||
.review__rating input { accent-color: #145a86; }
|
||||
.review__comment { width: 100%; min-height: 4rem; resize: vertical; }
|
||||
.review__document { position: sticky; inset-block-start: 1rem; min-height: 20rem; }
|
||||
.review__document-body { max-width: 75ch; overflow-wrap: anywhere; }
|
||||
.review__document-body img { max-width: 100%; height: auto; }
|
||||
.review__actions { position: fixed; inset-block-end: 0; inset-inline: 0; padding: .75rem 1rem; background: rgb(255 255 255 / .96); border-block-start: 1px solid #d9e0e7; text-align: end; }
|
||||
dialog { max-width: min(56rem, calc(100% - 2rem)); max-height: calc(100% - 2rem); border: 1px solid #8d9aaa; border-radius: .75rem; padding: 1rem; }
|
||||
dialog::backdrop { background: rgb(10 20 30 / .5); }
|
||||
.review__dialog-close { float: inline-end; }
|
||||
@media (max-width: 800px) {
|
||||
.review__workspace { grid-template-columns: 1fr; }
|
||||
.review__document { display: none; }
|
||||
.review__search { align-items: stretch; }
|
||||
.review__field, .review__field--small { flex-basis: 100%; }
|
||||
.review__search button { width: 100%; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="review__skip" href="#results">Перейти к результатам</a>
|
||||
<header class="review__header"><h1>Оценка поисковой выдачи</h1><p>Проверяйте результаты нашего OpenSearch по практическим юридическим запросам.</p></header>
|
||||
<main class="review__main" id="review">
|
||||
<form class="review__search" id="search-form">
|
||||
<label class="review__field">Запрос<input id="query" name="q" maxlength="500" required autocomplete="off"></label>
|
||||
<label class="review__field review__field--small">Язык<select id="language" name="language"><option value="ru">Русский</option><option value="ky">Кыргызский</option></select></label>
|
||||
<label class="review__field review__field--small">Результатов<select id="page-size" name="page_size"><option>10</option><option>20</option></select></label>
|
||||
<button class="review__button--primary" type="submit">Найти</button>
|
||||
</form>
|
||||
<div class="review__status" id="status" role="status" aria-live="polite"></div>
|
||||
<div class="review__workspace">
|
||||
<section class="review__results" aria-labelledby="results-heading"><h2 id="results-heading">Результаты</h2><ol class="review__results-list" id="results"></ol></section>
|
||||
<section class="review__document" aria-labelledby="document-heading"><h2 id="document-heading">Документ</h2><div id="document-meta">Выберите результат, чтобы открыть текст.</div><article class="review__document-body" id="document-body"></article></section>
|
||||
</div>
|
||||
</main>
|
||||
<div class="review__actions"><label>Проверяющий <input id="reviewer" maxlength="120" autocomplete="name"></label> <label>Общий комментарий <input id="overall-comment" maxlength="4000"></label> <button class="review__button--primary" id="save" type="button">Сохранить оценку</button></div>
|
||||
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
|
||||
<div class="review__status review__status--error" id="error" role="alert" aria-live="assertive"></div>
|
||||
<footer class="review__header">Акылдаш · Backend v0.9.0 · Внутренняя лаборатория релевантности</footer>
|
||||
<script>
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
const state = { results: [], token: '', selected: null, query: '' };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const setStatus = (text) => { $('status').textContent = text; $('error').textContent = ''; };
|
||||
const setError = (text) => { $('error').textContent = text; };
|
||||
const escapeText = (value) => value == null ? '' : String(value);
|
||||
const readDraft = () => { try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || 'null'); } catch (_) { return null; } };
|
||||
const saveDraft = () => { if (!state.token) return; const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked')?.value ?? null, comment: item.querySelector('textarea').value})); try { localStorage.setItem(DRAFT_KEY, JSON.stringify({query: state.query, language: $('language').value, pageSize: $('page-size').value, reviewer: $('reviewer').value, overallComment: $('overall-comment').value, results})); } catch (_) {} };
|
||||
const applyDraft = () => { const draft = readDraft(); if (!draft || draft.query !== state.query || draft.language !== $('language').value || draft.pageSize !== $('page-size').value) return; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; (draft.results || []).forEach((saved) => { const item = [...$('results').children].find((candidate) => candidate.dataset.rank === String(saved.rank) && candidate.dataset.code === saved.code); if (!item) return; if (saved.rating != null) { const input = item.querySelector(`input[value="${CSS.escape(String(saved.rating))}"]`); if (input) input.checked = true; } item.querySelector('textarea').value = saved.comment || ''; }); };
|
||||
function renderResults() {
|
||||
$('results').replaceChildren();
|
||||
state.results.forEach((result, index) => {
|
||||
const item = document.createElement('li'); item.className = 'review__result'; item.dataset.rank = index + 1; item.dataset.code = result.code;
|
||||
const title = document.createElement('button'); title.type = 'button'; title.className = 'review__result-title'; title.innerHTML = `<span class="review__rank">#${index + 1}</span><span></span>`; title.lastElementChild.textContent = escapeText(result.name || 'Название не указано'); title.addEventListener('click', () => openDocument(index, title));
|
||||
const meta = document.createElement('div'); meta.className = 'review__meta'; meta.textContent = [result.type, result.status, result.date_adopted, result.number].filter(Boolean).join(' · ');
|
||||
const snippet = document.createElement('div'); snippet.className = 'review__snippet'; snippet.textContent = result.snippet || 'Фрагмент не найден.';
|
||||
const rating = document.createElement('fieldset'); rating.className = 'review__rating'; rating.innerHTML = `<legend>Оценка результата</legend>`;
|
||||
[['0', 'нерелевантен'], ['1', 'косвенно полезен'], ['2', 'частично полезен'], ['3', 'прямо отвечает']].forEach(([value, label]) => { const id = `rating-${index}-${value}`; const wrapper = document.createElement('label'); wrapper.htmlFor = id; wrapper.textContent = `${value} — ${label}`; const input = document.createElement('input'); input.type = 'radio'; input.name = `rating-${index}`; input.id = id; input.value = value; wrapper.prepend(input); rating.append(wrapper); });
|
||||
const comment = document.createElement('textarea'); comment.className = 'review__comment'; comment.maxLength = 4000; comment.placeholder = 'Комментарий к результату (необязательно)'; comment.setAttribute('aria-label', `Комментарий к результату #${index + 1}`);
|
||||
item.append(title, meta, snippet, rating, comment); $('results').append(item);
|
||||
});
|
||||
applyDraft();
|
||||
}
|
||||
async function openDocument(index, trigger) {
|
||||
const result = state.results[index]; state.selected = trigger; setStatus('Загрузка документа…');
|
||||
try { const response = await fetch(`/documents/${encodeURIComponent(result.code)}/editions/${encodeURIComponent(result.edition)}?language=${encodeURIComponent($('language').value)}`); if (!response.ok) throw new Error('Документ недоступен'); const payload = await response.json(); const content = payload.content[$('language').value]; const meta = $('document-meta'); meta.textContent = [result.name, result.status, result.date_adopted].filter(Boolean).join(' · '); const source = document.createElement('a'); source.href = `https://cbd.minjust.gov.kg/${encodeURIComponent(result.code)}/edition/${encodeURIComponent(result.edition)}/${encodeURIComponent($('language').value)}`; source.target = '_blank'; source.rel = 'noreferrer'; source.textContent = ' Официальный источник'; meta.append(source); $('document-body').innerHTML = content.html; $('dialog-heading').textContent = result.name || 'Документ'; $('dialog-body').innerHTML = content.html; if (matchMedia('(max-width: 800px)').matches) $('document-dialog').showModal(); setStatus('Документ загружен.'); } catch (error) { setError('Не удалось загрузить документ. Повторите попытку.'); }
|
||||
}
|
||||
$('dialog-close').addEventListener('click', () => { $('document-dialog').close(); if (state.selected) state.selected.focus(); });
|
||||
$('search-form').addEventListener('submit', async (event) => { event.preventDefault(); const query = $('query').value.trim(); if (!query) return; state.query = query; setStatus('Поиск выполняется…'); $('save').disabled = true; try { const params = new URLSearchParams({q: query, language: $('language').value, page_size: $('page-size').value}); const response = await fetch(`/search?${params}`); if (!response.ok) throw new Error(); const payload = await response.json(); state.results = payload.results; state.token = payload.review_token; renderResults(); setStatus(state.results.length ? `Найдено результатов: ${state.results.length}.` : `По запросу «${query}» ничего не найдено. Измените запрос.`); } catch (error) { state.results = []; state.token = ''; renderResults(); setError('Не удалось выполнить поиск. Повторите поиск.'); } finally { $('save').disabled = false; } });
|
||||
document.addEventListener('input', saveDraft); document.addEventListener('change', saveDraft);
|
||||
$('save').addEventListener('click', async () => { if (!state.token) { setError('Сначала выполните поиск.'); return; } const reviewer = $('reviewer').value.trim(); if (!reviewer) { $('reviewer').focus(); setError('Укажите проверяющего.'); return; } const results = [...$('results').children].map((item, index) => ({rank: index + 1, code: item.dataset.code, rating: item.querySelector('input:checked') ? Number(item.querySelector('input:checked').value) : null, comment: item.querySelector('textarea').value})); $('save').disabled = true; setStatus('Сохранение выполняется…'); try { const response = await fetch('/search-reviews', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({review_token: state.token, reviewer, overall_comment: $('overall-comment').value, results})}); if (!response.ok) throw new Error(); const payload = await response.json(); localStorage.removeItem(DRAFT_KEY); setStatus(`Оценка сохранена · № ${payload.id}.`); } catch (error) { setError('Не удалось сохранить. Проверьте подключение и повторите.'); } finally { $('save').disabled = false; } });
|
||||
const draft = readDraft(); if (draft) { $('query').value = draft.query || ''; $('language').value = draft.language || 'ru'; $('page-size').value = draft.pageSize || '20'; $('reviewer').value = draft.reviewer || ''; $('overall-comment').value = draft.overallComment || ''; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
83
backend/search/reviews.py
Normal file
83
backend/search/reviews.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Persistence and signed snapshots for search relevance reviews."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MAX_COMMENT = 4000
|
||||
MAX_REVIEWER = 120
|
||||
|
||||
|
||||
class ReviewStore:
|
||||
def __init__(self, path: Path | str = ":memory:"):
|
||||
if path != ":memory:":
|
||||
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self.connection = sqlite3.connect(path, check_same_thread=False)
|
||||
self.connection.row_factory = sqlite3.Row
|
||||
# ponytail: one SQLite lock; split connections only if review throughput matters.
|
||||
self._lock = threading.Lock()
|
||||
self.connection.execute("""
|
||||
CREATE TABLE IF NOT EXISTS search_reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
reviewer TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
index_name TEXT NOT NULL,
|
||||
algorithm_version TEXT NOT NULL,
|
||||
top_result_code TEXT,
|
||||
results_json TEXT NOT NULL,
|
||||
overall_comment TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
self.connection.commit()
|
||||
|
||||
def save(self, review: dict) -> int:
|
||||
with self._lock:
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO search_reviews(created_at, reviewer, query, language, index_name, algorithm_version, top_result_code, results_json, overall_comment) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(review["created_at"], review["reviewer"], review["query"], review["language"], review["index_name"], review["algorithm_version"], review["top_result_code"], json.dumps(review["results"], ensure_ascii=False), review["overall_comment"]),
|
||||
)
|
||||
self.connection.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
def export(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return [
|
||||
{**dict(row), "results": json.loads(row["results_json"])}
|
||||
for row in self.connection.execute("SELECT * FROM search_reviews ORDER BY id")
|
||||
]
|
||||
|
||||
|
||||
class ReviewSnapshots:
|
||||
def __init__(self, secret: bytes | None = None, ttl: int = 3600):
|
||||
self.secret = secret or secrets.token_bytes(32)
|
||||
self.ttl = ttl
|
||||
|
||||
def create(self, query: str, language: str, index: str, results: list[dict], algorithm_version: str) -> str:
|
||||
payload = {"query": query, "language": language, "index_name": index, "algorithm_version": algorithm_version, "results": results, "expires_at": int(time.time()) + self.ttl}
|
||||
encoded = base64.urlsafe_b64encode(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode()).decode().rstrip("=")
|
||||
signature = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def verify(self, token: str) -> dict:
|
||||
try:
|
||||
encoded, signature = token.split(".", 1)
|
||||
expected = hmac.new(self.secret, encoded.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
raise ValueError
|
||||
payload = json.loads(base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)))
|
||||
if payload["expires_at"] < int(time.time()):
|
||||
raise ValueError
|
||||
return payload
|
||||
except (ValueError, KeyError, TypeError, json.JSONDecodeError, UnicodeError) as error:
|
||||
raise ValueError("invalid or expired search snapshot") from error
|
||||
156
backend/test_minjust_cbd.py
Normal file
156
backend/test_minjust_cbd.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import base64
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from email.message import Message
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from ingestion.minjust_cbd import CbdClient, SyncResult, progress_line, sync_archive
|
||||
|
||||
|
||||
class MinjustCbdTest(unittest.TestCase):
|
||||
def test_archives_document_and_resumes(self):
|
||||
image = base64.b64encode(b"image").decode()
|
||||
document = {
|
||||
"Code": 1,
|
||||
"Name": {"Rus": "Закон", "Kyr": "Мыйзам"},
|
||||
"Editions": [
|
||||
{
|
||||
"Code": 10,
|
||||
"Name": {"Rus": "01.01.2026", "Kyr": "01.01.2026"},
|
||||
"Data": {"Rus": "<p>Закон</p>", "Kyr": "<p>Мыйзам</p>"},
|
||||
"Images": [
|
||||
{
|
||||
"Lang": "Russian",
|
||||
"Name": "image001.jpg",
|
||||
"Data": image,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
client = Mock()
|
||||
client.total_documents = 1
|
||||
client.document_codes.return_value = [1]
|
||||
client.document.return_value = document
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory)
|
||||
first = sync_archive(output, client)
|
||||
second = sync_archive(output, client)
|
||||
refreshed = sync_archive(output, client, refresh=True)
|
||||
|
||||
self.assertEqual(first.downloaded, 1)
|
||||
self.assertEqual(second.skipped, 1)
|
||||
self.assertEqual(refreshed.downloaded, 1)
|
||||
self.assertEqual(client.document.call_count, 2)
|
||||
self.assertEqual(
|
||||
(output / "documents/1/editions/10/ru.html").read_text(),
|
||||
"<p>Закон</p>",
|
||||
)
|
||||
self.assertEqual(
|
||||
(output / "documents/1/editions/10/images/ru/image001.jpg").read_bytes(),
|
||||
b"image",
|
||||
)
|
||||
|
||||
def test_uses_stable_list_id_for_next_page(self):
|
||||
client = CbdClient(requests_per_second=1000)
|
||||
client.request_json = Mock(
|
||||
side_effect=[
|
||||
{
|
||||
"Id": "list-id",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||
},
|
||||
{
|
||||
"Id": "list-id",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 3}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||
self.assertEqual(
|
||||
client.request_json.call_args_list[1].args[0], "GetDocumentListById"
|
||||
)
|
||||
|
||||
def test_recreates_expired_list_at_current_page(self):
|
||||
client = CbdClient(requests_per_second=1000)
|
||||
client.request_json = Mock(
|
||||
side_effect=[
|
||||
{
|
||||
"Id": "expired-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||
},
|
||||
urllib.error.HTTPError("https://example.test", 404, "", {}, None),
|
||||
{
|
||||
"Id": "new-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 3}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||
recovery_call = client.request_json.call_args_list[2]
|
||||
self.assertEqual(recovery_call.args[0], "GetDocumentListByQuery")
|
||||
self.assertIn(("PageNumber", 2), recovery_call.args[1])
|
||||
|
||||
def test_recreates_list_after_page_retries_are_exhausted(self):
|
||||
client = CbdClient(requests_per_second=1000)
|
||||
client.request_json = Mock(
|
||||
side_effect=[
|
||||
{
|
||||
"Id": "failed-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 1}, {"Code": 2}],
|
||||
},
|
||||
RuntimeError("Ministry of Justice API request failed"),
|
||||
{
|
||||
"Id": "new-list",
|
||||
"TotalCount": 3,
|
||||
"Documents": [{"Code": 3}],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(list(client.document_codes(page_size=2)), [1, 2, 3])
|
||||
recovery_call = client.request_json.call_args_list[2]
|
||||
self.assertEqual(recovery_call.args[0], "GetDocumentListByQuery")
|
||||
self.assertIn(("PageNumber", 2), recovery_call.args[1])
|
||||
|
||||
def test_formats_progress_with_rate_and_eta(self):
|
||||
line = progress_line(
|
||||
SyncResult(discovered=50, downloaded=48, skipped=1, failed=1),
|
||||
total=100,
|
||||
elapsed=25,
|
||||
)
|
||||
|
||||
self.assertIn("50.00%", line)
|
||||
self.assertIn("rate=2.00/s", line)
|
||||
self.assertIn("ETA=00:00:25", line)
|
||||
|
||||
@patch("ingestion.minjust_cbd.time.sleep")
|
||||
@patch("ingestion.minjust_cbd.urllib.request.urlopen")
|
||||
def test_respects_retry_after_on_rate_limit(self, urlopen, sleep):
|
||||
headers = Message()
|
||||
headers["Retry-After"] = "7"
|
||||
response = Mock()
|
||||
response.read.return_value = b"{}"
|
||||
response.__enter__ = Mock(return_value=response)
|
||||
response.__exit__ = Mock(return_value=False)
|
||||
urlopen.side_effect = [
|
||||
urllib.error.HTTPError("https://example.test", 429, "", headers, None),
|
||||
response,
|
||||
]
|
||||
|
||||
CbdClient(requests_per_second=1000, retries=2).request_json("Example")
|
||||
|
||||
self.assertIn(((7.0,), {}), [(call.args, call.kwargs) for call in sleep.call_args_list])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
152
backend/test_minjust_normalization.py
Normal file
152
backend/test_minjust_normalization.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from normalization.minjust_cbd import normalize_archive
|
||||
|
||||
|
||||
class MinjustNormalizationTest(unittest.TestCase):
|
||||
def write_json(self, path: Path, value: object) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def edition(self, root: Path, code: int, languages: dict[str, str]) -> None:
|
||||
directory = root / "editions" / str(code)
|
||||
self.write_json(
|
||||
directory / "metadata.json",
|
||||
{"Code": code, "Name": {"Rus": "Редакция", "Kyr": "Редакция"}, "Type": "edition", "Images": []},
|
||||
)
|
||||
for language, content in languages.items():
|
||||
(directory / f"{language}.html").write_text(content, encoding="utf-8")
|
||||
|
||||
def document(self, root: Path, code: int = 1) -> Path:
|
||||
directory = root / "documents" / str(code)
|
||||
self.write_json(
|
||||
directory / "metadata.json",
|
||||
{
|
||||
"Code": code,
|
||||
"Class": {"Rus": "Акты", "Kyr": "Актылар"},
|
||||
"Type": {"Rus": "Закон", "Kyr": "Мыйзам"},
|
||||
"Title": {"Rus": " ", "Kyr": None},
|
||||
"Name": {"Rus": "Документ", "Kyr": "Документ"},
|
||||
"Status": {"Rus": "Действует", "Kyr": "Күчүндө"},
|
||||
"Number": "1",
|
||||
"DateAdopted": "2026-01-01",
|
||||
"IsPublicInCdb": True,
|
||||
"IsPublicInRegister": True,
|
||||
"Authorities": [],
|
||||
"SourcePublications": [],
|
||||
"Keywords": [],
|
||||
"GeneralClassifiers": [],
|
||||
"References": [],
|
||||
},
|
||||
)
|
||||
return directory
|
||||
|
||||
def test_languages_safety_empty_document_and_deterministic_fragments(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
document = self.document(source)
|
||||
self.edition(
|
||||
document,
|
||||
10,
|
||||
{
|
||||
"ru": '<meta charset=unicode><style>x</style><p style="color:red">Статья 1 Закон<script>bad()</script></p><a href="javascript:bad">ссылка</a><a href="http://[">сломанная ссылка</a><img src="http://[">',
|
||||
},
|
||||
)
|
||||
self.edition(document, 20, {"ky": "<p>1. Кыргызча жобо</p>"})
|
||||
self.edition(document, 30, {"ru": "<p>Русский</p>", "ky": "<p>Кыргызча</p>"})
|
||||
self.edition(document, 40, {})
|
||||
|
||||
first = normalize_archive(source, output)
|
||||
fragments_path = output / "documents/1/editions/10/ru/fragments.json"
|
||||
fragments = fragments_path.read_bytes()
|
||||
second = normalize_archive(source, output)
|
||||
|
||||
self.assertEqual((first.normalized, second.skipped), (1, 1))
|
||||
self.assertEqual(fragments, fragments_path.read_bytes())
|
||||
safe_html = (output / "documents/1/editions/10/ru/content.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("script", safe_html)
|
||||
self.assertNotIn("style=", safe_html)
|
||||
self.assertNotIn("javascript:", safe_html)
|
||||
self.assertNotIn("http://[", safe_html)
|
||||
self.assertIn("Статья 1 Закон", safe_html)
|
||||
parsed = json.loads(fragments)
|
||||
self.assertEqual(parsed[0]["type"], "article")
|
||||
self.assertEqual(parsed[0]["id"], "document:1:edition:10:lang:ru:fragment:1")
|
||||
self.assertEqual(len(parsed[0]["text_sha256"]), 64)
|
||||
canonical = json.loads((output / "documents/1/document.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(canonical["available_languages"], ["ru", "ky"])
|
||||
self.assertIsNone(canonical["title"]["ru"])
|
||||
empty = json.loads((output / "documents/1/editions/40/edition.json").read_text(encoding="utf-8"))
|
||||
self.assertFalse(empty["quality"]["has_html"])
|
||||
|
||||
def test_continues_after_bad_document_and_clears_repaired_error(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
bad = source / "documents/1"
|
||||
bad.mkdir(parents=True)
|
||||
(bad / "metadata.json").write_text("not json", encoding="utf-8")
|
||||
good = self.document(source, 2)
|
||||
self.edition(good, 10, {"ky": "<p>Берене 1 Текст</p>"})
|
||||
|
||||
with self.assertLogs("normalization.minjust_cbd", level="ERROR"):
|
||||
failed = normalize_archive(source, output)
|
||||
with sqlite3.connect(output / "manifest.sqlite3") as connection:
|
||||
state, failed_at = connection.execute(
|
||||
"SELECT state, failed_at FROM documents WHERE code='1'"
|
||||
).fetchone()
|
||||
self.assertEqual(state, "error")
|
||||
self.assertIsNotNone(failed_at)
|
||||
self.write_json(bad / "metadata.json", {"Code": 1, "Name": {"Rus": "Исправлен", "Kyr": None}})
|
||||
repaired = normalize_archive(source, output)
|
||||
|
||||
self.assertEqual((failed.failed, failed.normalized), (1, 1))
|
||||
self.assertEqual((repaired.normalized, repaired.skipped, repaired.failed), (1, 1, 0))
|
||||
with sqlite3.connect(output / "manifest.sqlite3") as connection:
|
||||
self.assertEqual(
|
||||
connection.execute(
|
||||
"SELECT state, error, failed_at FROM documents WHERE code='1'"
|
||||
).fetchone(),
|
||||
("success", None, None),
|
||||
)
|
||||
|
||||
def test_rejects_overlapping_input_and_output(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
source = Path(temporary) / "source"
|
||||
metadata = self.document(source) / "metadata.json"
|
||||
original = metadata.read_bytes()
|
||||
|
||||
for output in (source, source / "normalized", source.parent):
|
||||
with self.subTest(output=output):
|
||||
with self.assertRaisesRegex(ValueError, "must not overlap"):
|
||||
normalize_archive(source, output)
|
||||
self.assertEqual(metadata.read_bytes(), original)
|
||||
|
||||
def test_recovers_interrupted_directory_publication_before_skip(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
base = Path(temporary)
|
||||
source = base / "source"
|
||||
output = base / "normalized"
|
||||
self.document(source)
|
||||
first = normalize_archive(source, output)
|
||||
target = output / "documents/1"
|
||||
backup = output / "documents/.1.previous"
|
||||
os.replace(target, backup)
|
||||
|
||||
second = normalize_archive(source, output)
|
||||
|
||||
self.assertEqual((first.normalized, second.skipped), (1, 1))
|
||||
self.assertTrue((target / "document.json").is_file())
|
||||
self.assertFalse(backup.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
362
backend/test_minjust_opensearch.py
Normal file
362
backend/test_minjust_opensearch.py
Normal file
@@ -0,0 +1,362 @@
|
||||
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, switch_alias
|
||||
|
||||
|
||||
class MinjustOpenSearchTest(unittest.TestCase):
|
||||
def test_switches_alias_atomically_after_successful_load(self):
|
||||
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": True}) as request:
|
||||
switch_alias("http://127.0.0.1:9200/", "akyldash-fragments-v2", "akyldash-fragments-current")
|
||||
|
||||
self.assertEqual(request.call_args.args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertEqual(body["actions"][0], {"remove": {"index": "*", "alias": "akyldash-fragments-current", "must_exist": False}})
|
||||
self.assertEqual(body["actions"][1], {"add": {"index": "akyldash-fragments-v2", "alias": "akyldash-fragments-current"}})
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "differ"):
|
||||
switch_alias("http://127.0.0.1:9200", "same", "same")
|
||||
with self.assertRaisesRegex(ValueError, "differ"):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "")
|
||||
with patch("search.minjust_opensearch.request_json", return_value={"acknowledged": False}):
|
||||
with self.assertRaisesRegex(RuntimeError, "did not acknowledge"):
|
||||
switch_alias("http://127.0.0.1:9200", "index", "alias")
|
||||
|
||||
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.assertTrue(lines[1]["is_current_edition"])
|
||||
self.assertNotIn("text_ru", lines[1])
|
||||
self.assertEqual(lines[1]["legal_force_code"], "legislative")
|
||||
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": {}}]},
|
||||
{"acknowledged": True},
|
||||
]
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
root / "normalized",
|
||||
"http://127.0.0.1:9200",
|
||||
"test-index",
|
||||
maximum_bytes=4096,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
),
|
||||
(2, 2),
|
||||
)
|
||||
self.assertEqual(request.call_args_list[-2].args[3], "application/x-ndjson")
|
||||
self.assertEqual(request.call_args_list[-1].args[:2], ("http://127.0.0.1:9200/_aliases", "POST"))
|
||||
state = json.loads(checkpoint.read_text(encoding="utf-8"))
|
||||
self.assertEqual(state["last_document_code"], "7")
|
||||
self.assertTrue(state["complete"])
|
||||
|
||||
legacy = state.copy()
|
||||
legacy.pop("alias")
|
||||
legacy["schema_version"] = 1
|
||||
legacy["last_document_code"] = "8"
|
||||
legacy["complete"] = False
|
||||
checkpoint.write_text(json.dumps(legacy), 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"}}}},
|
||||
]
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
root / "normalized",
|
||||
"http://127.0.0.1:9200",
|
||||
"test-index",
|
||||
maximum_bytes=4096,
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
),
|
||||
(1, 0),
|
||||
)
|
||||
self.assertEqual(json.loads(checkpoint.read_text(encoding="utf-8"))["schema_version"], 2)
|
||||
|
||||
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,
|
||||
alias="test-current",
|
||||
)
|
||||
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, "alias"):
|
||||
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,
|
||||
alias="test-current",
|
||||
)
|
||||
with patch("search.minjust_opensearch.request_json") as request:
|
||||
request.side_effect = [
|
||||
{"cluster_uuid": "cluster-1"},
|
||||
{"test-index": {"settings": {"index": {"uuid": "index-1"}}}},
|
||||
{"acknowledged": True},
|
||||
]
|
||||
self.assertEqual(
|
||||
load_bulk(
|
||||
root / "normalized",
|
||||
"http://127.0.0.1:9200",
|
||||
"test-index",
|
||||
maximum_bytes=4096,
|
||||
resume=True,
|
||||
checkpoint=checkpoint,
|
||||
alias="test-current",
|
||||
),
|
||||
(1, 0),
|
||||
)
|
||||
self.assertTrue(all(call.args[1] == "GET" for call in request.call_args_list[:-1]))
|
||||
self.assertEqual(request.call_args_list[-1].args[1], "POST")
|
||||
|
||||
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,
|
||||
alias="test-current",
|
||||
)
|
||||
|
||||
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,
|
||||
alias="test-current",
|
||||
)
|
||||
|
||||
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()
|
||||
71
backend/test_search_api.py
Normal file
71
backend/test_search_api.py
Normal file
@@ -0,0 +1,71 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
|
||||
|
||||
class SearchApiTest(unittest.TestCase):
|
||||
def make_api(self, root: Path) -> Api:
|
||||
document = root / "documents/7"
|
||||
edition = document / "editions/10"
|
||||
edition.mkdir(parents=True)
|
||||
(document / "document.json").write_text(json.dumps({
|
||||
"source_code": "7", "available_languages": ["ru"],
|
||||
"editions": [{"source_code": "10", "available_languages": ["ru"]},],
|
||||
}), encoding="utf-8")
|
||||
(edition / "edition.json").write_text(json.dumps({"source_code": "10", "available_languages": ["ru"]}), encoding="utf-8")
|
||||
(edition / "ru").mkdir()
|
||||
(edition / "ru/content.html").write_text("<p>Текст</p>", encoding="utf-8")
|
||||
(edition / "ru/content.txt").write_text("Текст\n", encoding="utf-8")
|
||||
return Api("http://opensearch:9200", "current", root)
|
||||
|
||||
def test_search_pagination_filters_and_highlight(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}, "highlight": {"text_ru": ["<em>Закон</em>"]}}]}}
|
||||
with patch("search.api.request_json", return_value=response) as request:
|
||||
status, payload = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru&page=2&page_size=5&status=active")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["results"][0]["snippet"], "<em>Закон</em>")
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertEqual((body["from"], body["size"]), (5, 6))
|
||||
self.assertIn({"term": {"status_code": "active"}}, body["query"]["bool"]["filter"])
|
||||
with self.assertRaisesRegex(ApiError, "within 10000 results"):
|
||||
api.handle("GET", "/search?q=x&page=100&page_size=100")
|
||||
|
||||
def test_document_editions_openapi_and_validation(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
specification = api.handle("GET", "/openapi.json")[1]
|
||||
self.assertEqual(specification["info"]["version"], "v1")
|
||||
self.assertEqual(specification["paths"]["/documents/{code}"]["get"]["parameters"][0]["required"], True)
|
||||
self.assertEqual(specification["paths"]["/documents/{code}/editions/{edition}"]["get"]["parameters"][1]["name"], "edition")
|
||||
self.assertEqual(api.handle("GET", "/documents/7")[1]["current_edition"]["source_code"], "10")
|
||||
self.assertEqual(api.handle("GET", "/documents/7/editions/10?language=ru")[1]["content"]["ru"]["text"], "Текст\n")
|
||||
with self.assertRaisesRegex(ApiError, "q is required"):
|
||||
api.handle("GET", "/search")
|
||||
with self.assertRaisesRegex(ApiError, "date_from must be an ISO date"):
|
||||
api.handle("GET", "/search?q=x&date_from=tomorrow")
|
||||
with self.assertRaisesRegex(ApiError, "document not found"):
|
||||
api.handle("GET", "/documents/%2E%2E")
|
||||
|
||||
def test_filters_count_documents_and_return_bilingual_labels(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = self.make_api(Path(temporary))
|
||||
keys = {"document_types": "law", "statuses": "active", "authorities": "parliament", "legal_forces": "legislative"}
|
||||
response = {"aggregations": {name: {"buckets": [{"key": keys[name], "documents": {"value": 3}}]} for name in keys}}
|
||||
with patch("search.api.request_json", return_value=response) as request:
|
||||
payload = api.handle("GET", "/search/filters?language=ky")[1]
|
||||
self.assertEqual(payload["document_types"][0], {"code": "law", "labels": {"ru": "Закон", "ky": "Мыйзам"}, "count": 3})
|
||||
self.assertEqual(payload["legal_forces"][0]["labels"]["ky"], "Мыйзам деңгээли")
|
||||
body = json.loads(request.call_args.args[2])
|
||||
self.assertIn("terms", body["aggs"]["document_types"])
|
||||
self.assertIn("legal_force_code", body["aggs"]["legal_forces"]["terms"]["field"])
|
||||
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
41
backend/test_search_api_opensearch.py
Normal file
41
backend/test_search_api_opensearch.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from search.api import Api
|
||||
from search.minjust_opensearch import DEFAULT_MAPPING, request_json
|
||||
|
||||
|
||||
@unittest.skipUnless(os.getenv("AKYLDASH_OPENSEARCH_URL"), "set AKYLDASH_OPENSEARCH_URL to run against local OpenSearch")
|
||||
class SearchApiOpenSearchTest(unittest.TestCase):
|
||||
def test_current_editions_filters_and_catalogs(self):
|
||||
base_url = os.environ["AKYLDASH_OPENSEARCH_URL"].rstrip("/")
|
||||
index = f"akyldash-api-test-{uuid.uuid4().hex}"
|
||||
request_json(f"{base_url}/{index}", "PUT", DEFAULT_MAPPING.read_bytes(), "application/json")
|
||||
try:
|
||||
documents = [
|
||||
{"document_code": "1", "edition_code": "1", "is_current_edition": False, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "historic", "document_name_ru": "Old law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Утратил силу", "status_ky": "Күчүн жоготту", "status_code": "repealed", "date_adopted": "2020-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "1", "edition_code": "2", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current law", "document_type_ru": "Закон", "document_type_ky": "Мыйзам", "document_type_code": "law", "legal_force_code": "legislative", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2021-01-01", "authority_paths_ru": ["Парламент"], "authority_paths_ky": ["Парламент"], "authority_codes": ["parliament"]},
|
||||
{"document_code": "2", "edition_code": "1", "is_current_edition": True, "language": "ru", "position": 1, "fragment_type": "paragraph", "text_ru": "needle", "document_name_ru": "Current decree", "document_type_ru": "Указ", "document_type_ky": "Жарлык", "document_type_code": "decree", "legal_force_code": "subordinate", "status_ru": "Действует", "status_ky": "Күчүндө", "status_code": "active", "date_adopted": "2022-01-01", "authority_paths_ru": ["Президент"], "authority_paths_ky": ["Президент"], "authority_codes": ["president"]},
|
||||
]
|
||||
for number, document in enumerate(documents):
|
||||
request_json(f"{base_url}/{index}/_doc/{number}", "PUT", json.dumps(document).encode(), "application/json")
|
||||
request_json(f"{base_url}/{index}/_refresh", "POST", None, "application/json")
|
||||
api = Api(base_url, index, Path("."))
|
||||
self.assertEqual(api.handle("GET", "/search?q=historic")[1]["results"], [])
|
||||
filtered = api.handle("GET", "/search?q=needle&document_type=law")[1]
|
||||
self.assertEqual([result["code"] for result in filtered["results"]], ["1"])
|
||||
force_filtered = api.handle("GET", "/search?q=needle&legal_force=subordinate")[1]
|
||||
self.assertEqual([result["code"] for result in force_filtered["results"]], ["2"])
|
||||
self.assertEqual(force_filtered["results"][0]["legal_force"], "Подзаконный уровень")
|
||||
filters = api.handle("GET", "/search/filters")[1]
|
||||
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
|
||||
self.assertEqual({item["code"] for item in filters["legal_forces"]}, {"legislative", "subordinate"})
|
||||
finally:
|
||||
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
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()
|
||||
43
backend/test_search_reviews.py
Normal file
43
backend/test_search_reviews.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from search.api import Api, ApiError
|
||||
|
||||
|
||||
class SearchReviewTest(unittest.TestCase):
|
||||
def test_review_must_cover_each_snapshot_rank_once(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_index": "search-20260827", "_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}, {"_index": "search-20260827", "_source": {"document_code": "8", "edition_code": "11", "document_name_ru": "Кодекс"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=test&language=ru&page_size=2")[1]
|
||||
with self.assertRaisesRegex(ApiError, "exactly once"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3}]})
|
||||
|
||||
def test_saves_signed_search_snapshot_and_rejects_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary), Path(temporary) / "reviews.sqlite3", b"test-secret")
|
||||
response = {"hits": {"hits": [{"_source": {"document_code": "7", "edition_code": "10", "document_name_ru": "Закон"}}]}}
|
||||
with patch("search.api.request_json", return_value=response):
|
||||
result = api.handle("GET", "/search?q=%D0%B7%D0%B0%D0%BA%D0%BE%D0%BD&language=ru")[1]
|
||||
saved = api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "7", "rating": 3, "comment": "Прямой ответ"}]})
|
||||
self.assertEqual(saved[0], 201)
|
||||
exported = api.handle("GET", "/search-reviews/export")[1]["reviews"]
|
||||
self.assertEqual(exported[0]["top_result_code"], "7")
|
||||
self.assertEqual(exported[0]["results"][0]["rating"], 3)
|
||||
with self.assertRaisesRegex(ApiError, "does not match"):
|
||||
api.handle("POST", "/search-reviews", {"review_token": result["review_token"], "reviewer": "Юрист", "results": [{"rank": 1, "code": "8", "rating": 3}]})
|
||||
|
||||
def test_snapshot_and_review_page_are_available(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
api = Api("http://opensearch:9200", "current", Path(temporary))
|
||||
page = api.review_page()
|
||||
self.assertIn("Оценка поисковой выдачи", page)
|
||||
self.assertIn("akyldash-search-review-draft", page)
|
||||
self.assertIn("localStorage", page)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
102
backend/test_search_topic_taxonomy.py
Normal file
102
backend/test_search_topic_taxonomy.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import json
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
from tools.build_search_topic_taxonomy import build, group_for
|
||||
|
||||
|
||||
class SearchTopicTaxonomyTest(unittest.TestCase):
|
||||
def test_ambiguous_roots_are_left_for_manual_review(self):
|
||||
self.assertEqual(group_for("Иностранные инвестиции"), "review_required")
|
||||
|
||||
def test_preserves_source_tree_counts_and_marks_unclassified_roots(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "documents/1").mkdir(parents=True)
|
||||
(root / "documents/2").mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
|
||||
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
db.executemany("INSERT INTO documents VALUES (?, 'success')", [("1",), ("2",)])
|
||||
db.commit()
|
||||
classifier = {
|
||||
"Code": None,
|
||||
"Name": {"Rus": "ЗАКОНОДАТЕЛЬСТВО О ТРУДЕ", "Kyr": None},
|
||||
"GeneralClassifiers": [{
|
||||
"Code": "child-1",
|
||||
"Name": {"Rus": "Рабочее время", "Kyr": "Иш убактысы"},
|
||||
"GeneralClassifiers": [],
|
||||
}],
|
||||
}
|
||||
unknown = {
|
||||
"Code": None,
|
||||
"Name": {"Rus": "НЕИЗВЕСТНАЯ РУБРИКА", "Kyr": None},
|
||||
"GeneralClassifiers": [],
|
||||
}
|
||||
(root / "documents/1/document.json").write_text(
|
||||
json.dumps({"general_classifiers": [classifier]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "documents/2/document.json").write_text(
|
||||
json.dumps({"general_classifiers": [classifier, unknown]}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
catalog = build(root)
|
||||
groups = {group["code"]: group for group in catalog["groups"]}
|
||||
labor_root = groups["labor_social"]["children"][0]
|
||||
|
||||
self.assertEqual((catalog["source_document_count"], catalog["source_node_count"], catalog["source_root_count"]), (2, 3, 2))
|
||||
self.assertEqual(labor_root["document_count"], 2)
|
||||
self.assertEqual(labor_root["children"][0]["document_count"], 2)
|
||||
self.assertEqual(groups["review_required"]["children"][0]["grouping_status"], "manual_review")
|
||||
self.assertEqual(catalog["nodes_missing_kyrgyz_label"], 2)
|
||||
|
||||
def test_rebuild_fails_instead_of_omitting_successful_manifest_document(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "documents/1").mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
|
||||
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
db.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
db.commit()
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "normalized document 1"):
|
||||
build(root)
|
||||
|
||||
def test_rebuild_rejects_normalized_document_without_classifier_list(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
document = root / "documents/1"
|
||||
document.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
|
||||
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
db.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
db.commit()
|
||||
(document / "document.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "general_classifiers list"):
|
||||
build(root)
|
||||
|
||||
def test_rebuild_rejects_invalid_nested_classifier_branch(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
document = root / "documents/1"
|
||||
document.mkdir(parents=True)
|
||||
with closing(sqlite3.connect(root / "manifest.sqlite3")) as db:
|
||||
db.execute("CREATE TABLE documents (code TEXT, state TEXT)")
|
||||
db.execute("INSERT INTO documents VALUES ('1', 'success')")
|
||||
db.commit()
|
||||
(document / "document.json").write_text(
|
||||
json.dumps({"general_classifiers": [{"Name": {"Rus": "Корень"}, "GeneralClassifiers": "bad"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "non-list GeneralClassifiers branch"):
|
||||
build(root)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
3
deploy/local-opensearch/Dockerfile
Normal file
3
deploy/local-opensearch/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM opensearchproject/opensearch:3.7.0
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
|
||||
21
deploy/local-opensearch/compose.yaml
Normal file
21
deploy/local-opensearch/compose.yaml
Normal 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
|
||||
8
deploy/production/.env.example
Normal file
8
deploy/production/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# Absolute path on the production host containing minjust-normalized/.
|
||||
DATA_ROOT=/volume1/docker/akyldash/data
|
||||
BACKEND_PORT=8080
|
||||
SEARCH_INDEX=akyldash-fragments-current
|
||||
OPENSEARCH_MEM_LIMIT=4g
|
||||
OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g
|
||||
# Generate with: openssl rand -hex 32
|
||||
REVIEW_SECRET=replace-with-a-random-secret
|
||||
11
deploy/production/Dockerfile.backend
Normal file
11
deploy/production/Dockerfile.backend
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY backend /app/backend
|
||||
|
||||
ENV PYTHONPATH=/app/backend
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "-m", "search.api", "--host", "0.0.0.0", "--port", "8080", "--url", "http://opensearch:9200", "--index", "akyldash-fragments-current", "--data", "/app/data/minjust-normalized", "--reviews-db", "/app/data/search-reviews.sqlite3"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/review', timeout=3)"]
|
||||
3
deploy/production/Dockerfile.opensearch
Normal file
3
deploy/production/Dockerfile.opensearch
Normal file
@@ -0,0 +1,3 @@
|
||||
FROM opensearchproject/opensearch:3.7.0
|
||||
|
||||
RUN /usr/share/opensearch/bin/opensearch-plugin install --batch analysis-icu
|
||||
37
deploy/production/README.md
Normal file
37
deploy/production/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Production deployment
|
||||
|
||||
This compose project runs the Search API and its private OpenSearch node. It
|
||||
binds the API only to `127.0.0.1`; publish it through an authenticated reverse
|
||||
proxy or VPN. OpenSearch is not published outside the compose network.
|
||||
|
||||
The current compose intentionally disables the OpenSearch security plugin to
|
||||
match the existing API client. Keep both services on a private host/network
|
||||
until authenticated OpenSearch support is implemented.
|
||||
|
||||
## First deployment
|
||||
|
||||
1. Copy this directory to the host with the repository source.
|
||||
2. Copy `.env.example` to `.env`, set an absolute `DATA_ROOT`, and replace
|
||||
`REVIEW_SECRET` with a random value. Do not commit `.env`.
|
||||
3. Put the normalized dataset under `$DATA_ROOT/minjust-normalized/`.
|
||||
4. Check the rendered configuration:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml config
|
||||
```
|
||||
|
||||
5. Start the services:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env -f compose.yaml up -d --build
|
||||
docker compose --env-file .env -f compose.yaml ps
|
||||
curl -fsS http://127.0.0.1:${BACKEND_PORT:-8080}/review >/dev/null
|
||||
```
|
||||
|
||||
6. Load the versioned index and switch its alias only after the import and
|
||||
validation succeed. Back up `DATA_ROOT` and the `opensearch-data` volume
|
||||
before the first import.
|
||||
|
||||
This is a deployment baseline, not a public internet exposure recipe. TLS,
|
||||
authentication, backups, monitoring, and a production OpenSearch security
|
||||
configuration must be provided by the host reverse proxy/operations setup.
|
||||
64
deploy/production/compose.yaml
Normal file
64
deploy/production/compose.yaml
Normal file
@@ -0,0 +1,64 @@
|
||||
services:
|
||||
opensearch:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/production/Dockerfile.opensearch
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
discovery.type: single-node
|
||||
bootstrap.memory_lock: "true"
|
||||
DISABLE_SECURITY_PLUGIN: "true"
|
||||
OPENSEARCH_JAVA_OPTS: ${OPENSEARCH_JAVA_OPTS:--Xms2g -Xmx2g}
|
||||
mem_limit: ${OPENSEARCH_MEM_LIMIT:-4g}
|
||||
expose:
|
||||
- "9200"
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536
|
||||
hard: 65536
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9200/_cluster/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: deploy/production/Dockerfile.backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REVIEW_SECRET: ${REVIEW_SECRET:?set REVIEW_SECRET in .env}
|
||||
command:
|
||||
- python
|
||||
- -m
|
||||
- search.api
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8080"
|
||||
- --url
|
||||
- http://opensearch:9200
|
||||
- --index
|
||||
- ${SEARCH_INDEX:-akyldash-fragments-current}
|
||||
- --data
|
||||
- /app/data/minjust-normalized
|
||||
- --reviews-db
|
||||
- /app/data/search-reviews.sqlite3
|
||||
- --review-secret
|
||||
- ${REVIEW_SECRET}
|
||||
ports:
|
||||
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
|
||||
depends_on:
|
||||
opensearch:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_ROOT:?set DATA_ROOT in .env}:/app/data
|
||||
|
||||
volumes:
|
||||
opensearch-data:
|
||||
@@ -1,154 +0,0 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-08-03
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
Версия приложения: `0.2.1`
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Telegram-инфраструктура и первая версия бота-секретаря подготовлены. Бот сохраняет сообщения разрешённых тем в SQLite, экспортирует обсуждения в Markdown и постоянно запущен в Container Manager на Synology.
|
||||
|
||||
Ближайшая цель — 2026-08-04 зафиксировать MVP с командой, настроить резервное копирование SQLite и затем проверить полный рабочий цикл на реальном обсуждении.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
### Telegram
|
||||
|
||||
- Создана супергруппа `Акылдаш`.
|
||||
- Создан бот `help_clerk_bot`.
|
||||
- Бот добавлен в супергруппу с необходимыми правами.
|
||||
- Privacy Mode бота отключён.
|
||||
- Созданы и проверены рабочие темы.
|
||||
- Проверено подключение к Telegram Bot API.
|
||||
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
||||
|
||||
### ID тем
|
||||
|
||||
| Тема | `message_thread_id` | Состояние |
|
||||
|---|---:|---|
|
||||
| Общее | отсутствует | приветствие закреплено, тема закрыта |
|
||||
| MVP | `2` | подтверждено тестовым сообщением |
|
||||
| Решения | `4` | подтверждено тестовым сообщением |
|
||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||
| Работа с ИИ | `8` | материалы опубликованы, тема закрыта |
|
||||
| Отчеты | `37` | экспорт проверен, тема закрыта |
|
||||
|
||||
### Документация и правила
|
||||
|
||||
- Подготовлен общий план реализации в `TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md`.
|
||||
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-mvp-boundaries-and-rules.md`.
|
||||
- В MVP не входит автоматический вызов API языковой модели.
|
||||
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
||||
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
||||
- Подготовлены и опубликованы краткие навыки работы с ИИ для начинающих.
|
||||
- В `Общее` опубликовано и закреплено приветствие с назначением тем и описанием бота.
|
||||
|
||||
### Доступы
|
||||
|
||||
- Локальные доступы находятся в `credentials.json`.
|
||||
- `credentials.json` добавлен в `.gitignore`.
|
||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||
|
||||
### Репозиторий и приложение
|
||||
|
||||
- Реализован бот-секретарь версии `0.2.1` без внешних Python-зависимостей.
|
||||
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||
- Добавлены команды `/help`, `/status` и `/export`.
|
||||
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
|
||||
- Первый `/export` охватывает всю сохранённую тему, последующие начинаются после последней успешно созданной отсечки.
|
||||
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
|
||||
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
|
||||
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
|
||||
- Используется официальный образ `python:3.11-slim` и long polling.
|
||||
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
|
||||
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
|
||||
|
||||
## Пока не сделано
|
||||
|
||||
### Развёртывание и сопровождение
|
||||
|
||||
- Docker-конфигурация развёртывания не хранится в репозитории.
|
||||
- Не настроен CI/CD.
|
||||
|
||||
### Хранилище
|
||||
|
||||
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||
- Не утверждена окончательная схема PostgreSQL.
|
||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||
|
||||
### Функции бота
|
||||
|
||||
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||
- Ручная пометка отдельных сообщений для экспорта.
|
||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||
- Возврат отчёта пользователя в Telegram.
|
||||
|
||||
### Группа и рабочий процесс
|
||||
|
||||
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
По мере реального использования нужно принять решения по следующим вопросам:
|
||||
|
||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||
2. Правила хранения и удаления сообщений и вложений.
|
||||
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
### Фиксация MVP и проверка полного цикла
|
||||
|
||||
Рекомендуемый порядок:
|
||||
|
||||
1. Настроить резервное копирование `/volume1/docker/akyldash/data`.
|
||||
2. Зафиксировать состав MVP с командой и закрыть тему `MVP`.
|
||||
3. Провести одно реальное обсуждение с ответами, правками и вложением.
|
||||
4. Экспортировать обсуждение и вручную проанализировать Markdown в ChatGPT.
|
||||
5. Вернуть подтверждённый итог в тему `Решения` и зафиксировать его в Git/Gitea.
|
||||
6. Записать обнаруженные неудобства и только после этого выбирать следующую доработку.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-08-03
|
||||
|
||||
- Экспорт без параметров переведён с периода в семь дней на диапазон после предыдущей отсечки.
|
||||
- Бот развёрнут в Container Manager на Synology; автозапуск проверен перезапуском.
|
||||
- В теме `Работа с ИИ` опубликована первоначальная библиотека практик и отдельный материал о безопасной работе с Codex.
|
||||
- В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений.
|
||||
- Локальный файл доступов переведён из `credentials.txt` в `credentials.json`.
|
||||
|
||||
### 2026-08-02
|
||||
|
||||
- Экспорт ограничен Telegram ID владельца бота.
|
||||
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||
- Версия приложения обновлена до `0.2.0`.
|
||||
|
||||
### 2026-08-01
|
||||
|
||||
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||
|
||||
### 2026-07-31
|
||||
|
||||
- Создана супергруппа и добавлен бот.
|
||||
- Отключён Privacy Mode.
|
||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||
- Подтверждено отсутствие webhook.
|
||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш v0.2.1
|
||||
42
docs/README.md
Normal file
42
docs/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Документация Акылдаш
|
||||
|
||||
## Продукт
|
||||
|
||||
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||
правила работы с данными.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
эталонных документов и воспроизводимая оценка собственного поиска.
|
||||
- [Задание по нормализации документов](product/minjust-document-normalization-agent-task.md) —
|
||||
требования и критерии приёмки нормализатора ЦБД Минюста КР.
|
||||
|
||||
## Решения
|
||||
|
||||
- [Решение 001](decisions/001-telegram-workspace-mvp.md) — границы MVP
|
||||
Telegram-инфраструктуры.
|
||||
|
||||
## Эксплуатация и рабочий процесс
|
||||
|
||||
- [Текущий статус](operations/project-status.md) — выполненные шаги, риски и
|
||||
ближайшие действия.
|
||||
- [План Telegram-пространства](operations/telegram-workspace-plan.md) — темы,
|
||||
роли и этапы развития командного окружения.
|
||||
|
||||
## Backend
|
||||
|
||||
- [Выгрузка и нормализация ЦБД Минюста КР](../backend/README.md) — запуск,
|
||||
хранение и проверка конвейера правовых документов.
|
||||
|
||||
## Команда
|
||||
|
||||
- [Навыки работы с ИИ](team/ai-skills-for-beginners.md) — короткие практики для
|
||||
начинающих.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
2
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
2
docs/decisions/001-mvp-boundaries-and-rules.md → docs/decisions/001-telegram-workspace-mvp.md
Executable file → Normal file
@@ -78,4 +78,4 @@ Telegram позволяет запретить пользователям отп
|
||||
|
||||
---
|
||||
|
||||
Акылдаш v0.2.1
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
265
docs/operations/project-status.md
Normal file
265
docs/operations/project-status.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Статус проекта
|
||||
|
||||
Последняя проверка: 2026-09-14
|
||||
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.9.0`
|
||||
- Frontend: не создан
|
||||
|
||||
## Краткий итог
|
||||
|
||||
Репозиторий переориентирован с отдельного бота на весь проект юридической информационно-аналитической платформы. Telegram-бот выделен в инструмент рабочего окружения. Реализованы возобновляемая выгрузка документов из официального Open Data API ЦБД Минюста КР и их локальная воспроизводимая нормализация.
|
||||
|
||||
Поисковая система и внутренняя лаборатория оценки выдачи реализованы. Ближайшая
|
||||
цель — проверить собственную выдачу на практических RU/KY-запросах, разобрать
|
||||
оценки юристов и зафиксировать baseline. ЦБД Минюста служит источником для
|
||||
проверки документов и редакций, а не системой для сравнения поисковой выдачи.
|
||||
|
||||
## Уже сделано
|
||||
|
||||
### Telegram
|
||||
|
||||
- Создана супергруппа `Акылдаш`.
|
||||
- Создан бот `help_clerk_bot`.
|
||||
- Бот добавлен в супергруппу с необходимыми правами.
|
||||
- Privacy Mode бота отключён.
|
||||
- Созданы и проверены рабочие темы.
|
||||
- Проверено подключение к Telegram Bot API.
|
||||
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
|
||||
|
||||
### ID тем
|
||||
|
||||
| Тема | `message_thread_id` | Состояние |
|
||||
|---|---:|---|
|
||||
| Общее | отсутствует | приветствие закреплено, тема закрыта |
|
||||
| MVP | `2` | подтверждено тестовым сообщением |
|
||||
| Решения | `4` | подтверждено тестовым сообщением |
|
||||
| Обсуждение | `6` | подтверждено тестовым сообщением |
|
||||
| Работа с ИИ | `8` | материалы опубликованы, тема закрыта |
|
||||
| Отчеты | `37` | экспорт проверен, тема закрыта |
|
||||
|
||||
### Документация и правила
|
||||
|
||||
- Подготовлен общий план реализации в `docs/operations/telegram-workspace-plan.md`.
|
||||
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-telegram-workspace-mvp.md`.
|
||||
- Добавлены обзор всей платформы и единый индекс документации.
|
||||
- В MVP не входит автоматический вызов API языковой модели.
|
||||
- Анализ экспортов выполняется вручную в обычном ChatGPT.
|
||||
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
|
||||
- Подготовлены и опубликованы краткие навыки работы с ИИ для начинающих.
|
||||
- В `Общее` опубликовано и закреплено приветствие с назначением тем и описанием бота.
|
||||
|
||||
### Доступы
|
||||
|
||||
- Локальные доступы находятся в `credentials.json`.
|
||||
- `credentials.json` добавлен в `.gitignore`.
|
||||
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
|
||||
|
||||
### Репозиторий и приложение
|
||||
|
||||
- Реализован бот-секретарь версии `0.2.2` без внешних Python-зависимостей.
|
||||
- Код бота выделен из корня репозитория в `tools/telegram-bot`.
|
||||
- Сообщения и полные Telegram-метаданные сохраняются в SQLite.
|
||||
- Добавлены команды `/help`, `/status` и `/export`.
|
||||
- Экспорт доступен только владельцу, указанному в `TELEGRAM_OWNER_ID`.
|
||||
- Первый `/export` охватывает всю сохранённую тему, последующие начинаются после последней успешно созданной отсечки.
|
||||
- Отчёты публикуются в закрытой теме `Отчеты` (`message_thread_id=37`).
|
||||
- Исходное обсуждение завершается заметной отсечкой со ссылкой и хэштегом отчёта.
|
||||
- Локальный Git-репозиторий восстановлен и привязан к Gitea.
|
||||
- Репозиторий организован как основа всего проекта, а не отдельного бота.
|
||||
- Бот развёрнут в Container Manager на Synology; автозапуск после перезапуска менеджера проверен.
|
||||
- Реализован backend-загрузчик ЦБД Минюста КР версии `0.2.2` без внешних зависимостей.
|
||||
- Загрузчик сохраняет метаданные, редакции RU/KY и изображения, а прогресс — в SQLite.
|
||||
- Пилотная выгрузка двух документов и возобновление без повторного скачивания проверены на живом API.
|
||||
- Реализован backend-нормализатор версии `0.2.2` без внешних зависимостей.
|
||||
- Нормализатор создаёт канонические метаданные, безопасный HTML, чистый текст и адресуемые фрагменты RU/KY.
|
||||
- SQLite-манифест обеспечивает возобновление, повтор ошибок и пропуск неизменившихся документов.
|
||||
- Полный проход завершён: 209 958 документов нормализованы без ошибок.
|
||||
- Контрольная выборка RU/KY прошла проверки текста, фрагментов, ID и SHA-256.
|
||||
- Добавлены строгий mapping и атомарный Bulk NDJSON-экспорт для OpenSearch.
|
||||
- На предыдущей итерации использовались Excel/PDF-бланки юридической проверки;
|
||||
текущий процесс опирается на relevance set и внутреннюю лабораторию.
|
||||
|
||||
### Развёртывание
|
||||
|
||||
- Бот постоянно запущен на Synology в контейнере `akyldash-bot`.
|
||||
- Используется официальный образ `python:3.11-slim` и long polling.
|
||||
- Контейнер работает без root, с read-only root filesystem и политикой `unless-stopped`.
|
||||
- Доступ к Telegram Bot API идёт через отдельный закрытый прокси-контейнер без опубликованных наружу портов.
|
||||
- Код, закрытый env-файл и SQLite хранятся в `/volume1/docker/akyldash`.
|
||||
|
||||
## Пока не сделано
|
||||
|
||||
### Развёртывание и сопровождение
|
||||
|
||||
- Docker-конфигурация развёртывания не хранится в репозитории.
|
||||
- Не настроен CI/CD.
|
||||
|
||||
### Хранилище
|
||||
|
||||
- PostgreSQL не подключён; первая версия использует SQLite.
|
||||
- Не утверждена окончательная схема PostgreSQL.
|
||||
- Не определены сроки хранения сообщений, вложений и экспортов.
|
||||
|
||||
### Функции бота
|
||||
|
||||
- Скачивание файлов вложений; сейчас сохраняются их Telegram-метаданные.
|
||||
- Ручная пометка отдельных сообщений для экспорта.
|
||||
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
|
||||
- Возврат отчёта пользователя в Telegram.
|
||||
|
||||
### Группа и рабочий процесс
|
||||
|
||||
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
|
||||
- Не настроены уведомления Gitea.
|
||||
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
|
||||
|
||||
### Готовность поиска к frontend
|
||||
|
||||
- Реализована внутренняя лаборатория для просмотра и оценки фактической
|
||||
выдачи OpenSearch; пилот с юристами ещё не проведён.
|
||||
- Не завершена независимая юридическая проверка всех 50 запросов relevance set
|
||||
v1; спорные строки нельзя включать в baseline.
|
||||
- Не зафиксированы неизменяемая копия подтверждённого relevance set и baseline
|
||||
`Recall@10`/`MRR@10`.
|
||||
- Не пройден финальный readiness gate: обновление корпуса, переиндексация,
|
||||
проверка выдачи и API-сценарии для RU, KY и одноязычных актов.
|
||||
|
||||
## Важные неизвестные
|
||||
|
||||
По мере реального использования нужно принять решения по следующим вопросам:
|
||||
|
||||
1. Адрес и параметры PostgreSQL для разработки и Synology.
|
||||
2. Правила хранения и удаления сообщений и вложений.
|
||||
3. Нужен ли webhook после постоянного развёртывания; первая версия работает через long polling.
|
||||
|
||||
## Следующий этап
|
||||
|
||||
### Проверка собственной поисковой выдачи
|
||||
|
||||
1. Подготовить согласованный RU/KY relevance set: фиксировать потребность
|
||||
запроса и подтверждённые `document_code`, не ориентируясь на порядок выдачи.
|
||||
2. Проверить практические запросы во внутренней лаборатории `/review`, сохранить
|
||||
снимки и оценки фактических результатов OpenSearch.
|
||||
3. Разобрать ошибки ранжирования, сохранить baseline и повторить ту же проверку
|
||||
после изменений.
|
||||
4. Затем пройти readiness gate issue #21 для корпуса, индекса и Search API перед
|
||||
проектированием публичного frontend.
|
||||
|
||||
## История изменений статуса
|
||||
|
||||
### 2026-09-12
|
||||
|
||||
- Внутренняя лаборатория оценивает фактическую выдачу собственного OpenSearch;
|
||||
ЦБД Минюста используется только для проверки источников и редакций актов.
|
||||
- Backend `0.8.3`; закрытая внутренняя лаборатория готова к пилоту.
|
||||
|
||||
### 2026-09-06
|
||||
|
||||
- Подготовлены бланки независимой юридической проверки relevance set v1 для
|
||||
русского и кыргызского языков и инструкция для юриста.
|
||||
- Ближайшим этапом зафиксированы юридическая верификация, baseline качества
|
||||
поиска и финальный readiness gate перед frontend.
|
||||
|
||||
### 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
|
||||
|
||||
- Нормализатор запрещает пересекающиеся каталоги источника и результата,
|
||||
восстанавливает прерванную публикацию и отбрасывает некорректные URL.
|
||||
- Версия backend обновлена до `0.2.2`.
|
||||
- Загрузчик пересоздаёт временный список документов на текущей странице не
|
||||
только после HTTP 404, но и после исчерпания повторов запроса списка.
|
||||
- Версия backend обновлена до `0.2.1`.
|
||||
|
||||
### 2026-08-10
|
||||
|
||||
- Добавлена первая версия воспроизводимой нормализации локального архива ЦБД.
|
||||
- Добавлены атомарная публикация результатов, контрольные суммы, карантин ошибок и терминальный прогресс.
|
||||
- Версия backend обновлена до `0.2.0`.
|
||||
|
||||
### 2026-08-06
|
||||
|
||||
- Добавлен терминальный прогрессбар со скоростью и расчётным временем завершения.
|
||||
- Обработка HTTP 429 учитывает рекомендованную сервером задержку `Retry-After`.
|
||||
- Добавлено автоматическое восстановление после истечения серверного списка документов.
|
||||
- Версия backend обновлена до `0.1.2`.
|
||||
|
||||
### 2026-08-05
|
||||
|
||||
- Создана область `backend/ingestion` для получения правовых источников.
|
||||
- Реализована возобновляемая выгрузка ЦБД Минюста КР версии `0.1.0`.
|
||||
- Проверены двуязычные редакции, изображения, SQLite-манифест и повторный запуск.
|
||||
|
||||
### 2026-08-03
|
||||
|
||||
- Репозиторий перестроен под весь проект юридической платформы; Telegram-бот перенесён в `tools/telegram-bot`.
|
||||
- Созданы обзор продукта и структурированный индекс документации; будущие каталоги решено создавать по фактическим задачам.
|
||||
- Закрытые юридические материалы решено хранить отдельно от основного репозитория.
|
||||
- Версия Telegram-бота обновлена до `0.2.2` из-за изменения структуры запуска.
|
||||
- Экспорт без параметров переведён с периода в семь дней на диапазон после предыдущей отсечки.
|
||||
- Бот развёрнут в Container Manager на Synology; автозапуск проверен перезапуском.
|
||||
- В теме `Работа с ИИ` опубликована первоначальная библиотека практик и отдельный материал о безопасной работе с Codex.
|
||||
- В `Общее` опубликовано и закреплено приветствие; тема закрыта для сообщений.
|
||||
- Локальный файл доступов переведён из `credentials.txt` в `credentials.json`.
|
||||
|
||||
### 2026-08-02
|
||||
|
||||
- Экспорт ограничен Telegram ID владельца бота.
|
||||
- Технические ответы на корневое сообщение темы исключены из Markdown.
|
||||
- Экспорт перенесён в тему `Отчеты`, добавлены отсечки и навигационные хэштеги.
|
||||
- Проверена живая публикация отчёта и отсечки через Telegram Bot API.
|
||||
- Бот версии `0.2.0` развёрнут в Container Manager на Synology.
|
||||
- Версия приложения обновлена до `0.2.0`.
|
||||
|
||||
### 2026-08-01
|
||||
|
||||
- Восстановлена связь локального каталога с репозиторием Gitea.
|
||||
- Реализован бот-секретарь версии `0.1.0` с SQLite и Markdown-экспортом.
|
||||
- Проверены токен `help_clerk_bot` и доступ к супергруппе `Акылдаш`.
|
||||
|
||||
### 2026-07-31
|
||||
|
||||
- Создана супергруппа и добавлен бот.
|
||||
- Отключён Privacy Mode.
|
||||
- Определены ID тем: `2`, `4`, `6`, `8`.
|
||||
- Подтверждено отсутствие webhook.
|
||||
- Зафиксировано, что код бота и хранилище ещё не реализованы.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.9.0 · Frontend — не создан
|
||||
2
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
2
TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md → docs/operations/telegram-workspace-plan.md
Executable file → Normal file
@@ -398,4 +398,4 @@ Git сохраняет актуальную версию
|
||||
|
||||
---
|
||||
|
||||
Акылдаш v0.2.1
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
127
docs/product/frontend-design-readiness-plan.md
Normal file
127
docs/product/frontend-design-readiness-plan.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Готовность к проектированию 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. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
|
||||
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
|
||||
запросы без установленного эталонного результата.
|
||||
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.
|
||||
261
docs/product/frontend-search-sps-plan.md
Normal file
261
docs/product/frontend-search-sps-plan.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# План реализации frontend поисковой СПС
|
||||
|
||||
Основание: `docs/Функциональные_возможности_поисковой_СПС.docx`.
|
||||
|
||||
Документ с требованиями описывает не только frontend, но и поиск, юридическую
|
||||
обработку, персональные данные, уведомления и внешние источники. Поэтому
|
||||
frontend следует начинать после появления нормализованной базы, поискового API
|
||||
и API документов.
|
||||
|
||||
Требования необходимо адаптировать под Кыргызскую Республику: в исходном
|
||||
документе используются примеры ТК РФ и деление
|
||||
«федеральный/региональный/муниципальный», которое нельзя переносить без
|
||||
изменений.
|
||||
|
||||
## Задачи до начала frontend-разработки
|
||||
|
||||
### Обязательные для MVP
|
||||
|
||||
1. Нормализовать архив Минюста:
|
||||
- очистить HTML;
|
||||
- выделить структуру документа и редакций;
|
||||
- унифицировать статусы, органы, виды документов и даты;
|
||||
- сохранить ссылки на официальный источник и дату получения;
|
||||
- определить правила отображения документов без текста.
|
||||
2. Подготовить backend API:
|
||||
- `GET /search`;
|
||||
- `GET /search/filters`;
|
||||
- `GET /documents/{code}`;
|
||||
- `GET /documents/{code}/editions`;
|
||||
- `GET /documents/{code}/editions/{edition}`;
|
||||
- описание API в OpenAPI;
|
||||
- серверную пагинацию, фильтрацию и сортировку.
|
||||
3. Развернуть поисковый сервис. Рекомендуемый вариант — self-hosted OpenSearch:
|
||||
- отдельные поля и анализаторы для русского и кыргызского текстов;
|
||||
- русский морфологический анализ;
|
||||
- ICU-нормализация кыргызского текста;
|
||||
- словари синонимов и сокращений;
|
||||
- подсветка совпадений;
|
||||
- индексирование всех редакций.
|
||||
4. Подготовить тестовый набор из 50–100 реальных запросов на русском и
|
||||
кыргызском языках и вручную отметить ожидаемые результаты.
|
||||
5. Утвердить справочники:
|
||||
- виды документов;
|
||||
- органы принятия;
|
||||
- статусы;
|
||||
- уровни действия;
|
||||
- тематический классификатор первой версии.
|
||||
|
||||
OpenSearch имеет встроенный
|
||||
[русский морфологический анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/russian/),
|
||||
поддерживает [синонимы и нечёткий поиск](https://docs.opensearch.org/latest/query-dsl/full-text/match/)
|
||||
и [подсветку результатов](https://docs.opensearch.org/latest/search-plugins/searching-data/highlight).
|
||||
Встроенного кыргызского морфологического анализатора в перечне нет, поэтому
|
||||
нужно отдельно проверить ICU и словари на реальных запросах.
|
||||
[ICU-анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/icu/)
|
||||
обеспечивает Unicode-нормализацию, но сам по себе не гарантирует кыргызскую
|
||||
морфологию.
|
||||
|
||||
### Сторонние сервисы, не нужные для MVP
|
||||
|
||||
Их не следует подключать заранее:
|
||||
|
||||
- Keycloak или другой OIDC-провайдер — перед закладками, папками и ролями;
|
||||
- SMTP, Telegram или Web Push — перед «документами на контроле»;
|
||||
- LibreOffice или Gotenberg — перед экспортом в Word, PDF и RTF;
|
||||
- поставщики судебной практики и экспертных комментариев — после проверки
|
||||
лицензий;
|
||||
- источники курсов, календарей и справочных данных — перед соответствующим
|
||||
разделом;
|
||||
- Sentry или аналог — опционально перед публичным запуском.
|
||||
|
||||
## Граница MVP
|
||||
|
||||
MVP — публичная справочно-поисковая система без регистрации и персональных
|
||||
функций.
|
||||
|
||||
В MVP входят:
|
||||
|
||||
- интерфейс на русском и кыргызском языках;
|
||||
- строка полнотекстового поиска;
|
||||
- исправление распространённых опечаток;
|
||||
- базовые синонимы и сокращения;
|
||||
- список результатов с подсвеченными фрагментами;
|
||||
- фильтры по языку, виду документа, органу, статусу и дате;
|
||||
- сортировка по релевантности и дате;
|
||||
- пагинация;
|
||||
- карточка документа;
|
||||
- актуальная редакция, статус и дата актуальности;
|
||||
- переключение между доступными языками;
|
||||
- поиск внутри открытого документа;
|
||||
- список редакций и открытие выбранной редакции;
|
||||
- ссылка на официальный источник и сведения о происхождении данных;
|
||||
- адаптивность, доступность, состояния загрузки и ошибок.
|
||||
|
||||
Сравнение редакций, аккаунты, заметки, уведомления, RAG и судебная практика в
|
||||
MVP не входят.
|
||||
|
||||
Интерфейс не должен предполагать наличие обоих языков. На момент полного
|
||||
скачивания архива распределение следующее:
|
||||
|
||||
- только русский язык — 29 433 документа;
|
||||
- только кыргызский язык — 98 905 документов;
|
||||
- оба языка — 80 930 документов;
|
||||
- нет HTML-текста — 690 документов.
|
||||
|
||||
## Рекомендуемая основа frontend
|
||||
|
||||
- Next.js App Router и TypeScript;
|
||||
- CSS Modules с BEM-именованием;
|
||||
- дизайн-токены для цветов, отступов, типографики и состояний;
|
||||
- серверный `fetch` и URL-параметры вместо отдельного глобального хранилища;
|
||||
- Playwright для основных пользовательских сценариев;
|
||||
- адаптивный web-интерфейс без отдельного мобильного приложения.
|
||||
|
||||
Next.js App Router поддерживает серверные компоненты, маршрутизацию и
|
||||
TypeScript в стандартной конфигурации. См.
|
||||
[официальную документацию](https://nextjs.org/docs/app).
|
||||
|
||||
## Дизайн-процесс и внешние ориентиры
|
||||
|
||||
При проектировании и проверке интерфейса используются следующие источники:
|
||||
|
||||
- [jakubkrehel/skills](https://github.com/jakubkrehel/skills) — обязательная
|
||||
комплексная проверка интерфейса через `better-interface`, включая UI,
|
||||
типографику, цвета, доступность, layout и тексты;
|
||||
- [UI Skills](https://www.ui-skills.com/) — каталог практик и узких skills,
|
||||
которые подключаются только под конкретную задачу после проверки их
|
||||
содержания и лицензии;
|
||||
- [Refero Styles](https://styles.refero.design/) — библиотека визуальных
|
||||
направлений и примеров `DESIGN.md` для поиска референсов.
|
||||
|
||||
Правила применения:
|
||||
|
||||
1. До разработки экранов выбрать в Refero не более трёх подходящих направлений
|
||||
и на их основе утвердить одно собственное направление Акылдаша.
|
||||
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
|
||||
компоненты должны учитывать длинные юридические тексты, два языка и
|
||||
доступность.
|
||||
3. Зафиксировать утверждённое направление в `frontend/DESIGN.md` и перенести
|
||||
значения в дизайн-токены проекта.
|
||||
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
|
||||
рекомендации не могут отменять BEM, доступность, требования безопасности и
|
||||
продуктовые ограничения проекта.
|
||||
5. Каждый завершённый пользовательский сценарий проходит `better-interface`
|
||||
review. Перед выпуском MVP выполняется полный review поиска, фильтров и
|
||||
просмотра документа.
|
||||
6. UI Skills используется для точечного поиска решения, а не для одновременного
|
||||
смешивания нескольких визуальных стилей.
|
||||
|
||||
Эти ресурсы используются на этапе проектирования и review и не являются
|
||||
runtime-зависимостями frontend. Регистрация в стороннем SaaS для MVP не нужна.
|
||||
|
||||
## План спринтов MVP
|
||||
|
||||
### Спринт 0 — фундамент, 1 неделя
|
||||
|
||||
- создать `frontend/`;
|
||||
- настроить Next.js, TypeScript, lint и сборку;
|
||||
- выбрать до трёх референсов в Refero Styles и утвердить одно визуальное
|
||||
направление;
|
||||
- создать `frontend/DESIGN.md` с правилами выбранного направления;
|
||||
- установить полный набор `jakubkrehel/skills` для проектных design review;
|
||||
- определить маршруты и типы API;
|
||||
- создать дизайн-токены;
|
||||
- реализовать базовые компоненты: кнопка, поле, селект, статус, карточка,
|
||||
пагинация;
|
||||
- создать общий layout и двуязычную навигацию;
|
||||
- добавить footer с версиями frontend и backend;
|
||||
- подготовить макеты поиска, результатов и документа;
|
||||
- провести первый `better-interface` review макетов;
|
||||
- настроить CI.
|
||||
|
||||
Результат: интерфейсный каркас работает на mock-ответах API.
|
||||
|
||||
### Спринт 1 — быстрый поиск, 2 недели
|
||||
|
||||
- главная страница с поиском;
|
||||
- интеграция с `/search`;
|
||||
- список результатов;
|
||||
- подсветка совпадений;
|
||||
- URL, которым можно поделиться;
|
||||
- переключение RU/KY;
|
||||
- состояния загрузки, отсутствия результатов и ошибки API;
|
||||
- базовая мобильная версия.
|
||||
|
||||
Результат: пользователь может найти документ и открыть результат.
|
||||
|
||||
### Спринт 2 — точный отбор, 2 недели
|
||||
|
||||
- фильтры по реквизитам;
|
||||
- сортировка;
|
||||
- пагинация;
|
||||
- отображение числа результатов;
|
||||
- сброс отдельных и всех фильтров;
|
||||
- сохранение состояния в URL;
|
||||
- доступное управление с клавиатуры;
|
||||
- адаптивная панель фильтров.
|
||||
|
||||
Результат: поддерживается быстрый и реквизитный поиск.
|
||||
|
||||
### Спринт 3 — просмотр документа, 2 недели
|
||||
|
||||
- заголовок, реквизиты, статус и дата актуальности;
|
||||
- безопасное отображение очищенного HTML;
|
||||
- переключение языка;
|
||||
- поиск внутри документа;
|
||||
- навигация по найденным фрагментам;
|
||||
- список редакций;
|
||||
- открытие предыдущей редакции;
|
||||
- ссылка на ЦБД Минюста;
|
||||
- печать средствами браузера.
|
||||
|
||||
Результат: пользователь может проверить текст и его происхождение.
|
||||
|
||||
### Спринт 4 — стабилизация и выпуск, 2 недели
|
||||
|
||||
- сквозные тесты поиска и просмотра;
|
||||
- проверка русских, кыргызских и одноязычных документов;
|
||||
- соответствие WCAG 2.2 AA;
|
||||
- защита от внедрения небезопасного HTML;
|
||||
- проверка производительности;
|
||||
- корректные метаданные страниц;
|
||||
- обработка недоступности API;
|
||||
- production-сборка и развёртывание;
|
||||
- пользовательское тестирование на 10–15 реальных юридических задачах.
|
||||
|
||||
Результат: публичный MVP.
|
||||
|
||||
Оценка frontend-части после готовности API: **9 недель**.
|
||||
|
||||
## Спринты после MVP
|
||||
|
||||
### Спринт 5 — персональный кабинет
|
||||
|
||||
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
|
||||
|
||||
### Спринт 6 — контроль изменений
|
||||
|
||||
Документы на контроле, подписки на редакции и уведомления.
|
||||
|
||||
### Спринт 7 — юридические связи
|
||||
|
||||
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
|
||||
|
||||
### Спринт 8 — практические материалы
|
||||
|
||||
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
|
||||
|
||||
### Спринт 9 — расширенный анализ
|
||||
|
||||
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
|
||||
ссылками на источники.
|
||||
|
||||
### Спринт 10 — корпоративные функции
|
||||
|
||||
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
|
||||
персонализация.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
217
docs/product/minjust-document-normalization-agent-task.md
Normal file
217
docs/product/minjust-document-normalization-agent-task.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# Задание агенту: нормализация документов ЦБД Минюста КР
|
||||
|
||||
## Цель
|
||||
|
||||
Реализовать первую рабочую версию воспроизводимого нормализатора локального
|
||||
архива `data/minjust-cbd`. Нормализованные данные должны быть пригодны для
|
||||
последующей загрузки в OpenSearch, backend API и RAG, но подключение этих
|
||||
сервисов в текущую задачу не входит.
|
||||
|
||||
## Текущее состояние
|
||||
|
||||
- загрузчик находится в `backend/ingestion/minjust_cbd.py`;
|
||||
- сырой архив хранится в `data/minjust-cbd` и исключён из Git;
|
||||
- в манифесте 209 811 успешно загруженных документов;
|
||||
- 13 кодов остаются в таблице `errors` и отсутствуют в таблице `documents`;
|
||||
- документ содержит `metadata.json` и каталог `editions`;
|
||||
- редакция содержит `metadata.json`, `ru.html` и/или `ky.html`, иногда
|
||||
изображения;
|
||||
- HTML создан Microsoft Word, может содержать некорректный
|
||||
`<meta charset=unicode>`, служебные стили и неполную разметку;
|
||||
- файлы архива записаны загрузчиком в UTF-8;
|
||||
- часть документов одноязычная, а часть не содержит HTML-текста.
|
||||
|
||||
## Обязательные ограничения
|
||||
|
||||
1. Не изменять и не перезаписывать `data/minjust-cbd`.
|
||||
2. Не запускать полный проход по архиву во время автоматических тестов.
|
||||
3. Не подключать PostgreSQL, OpenSearch, OCR, embeddings, машинный перевод и
|
||||
сетевые API.
|
||||
4. Сначала использовать стандартную библиотеку. Новая зависимость допустима
|
||||
только если на реальных образцах доказано, что стандартный HTML-парсер не
|
||||
обеспечивает корректность или безопасность.
|
||||
5. Все записи выполнять атомарно.
|
||||
6. Ошибка одного документа не должна останавливать длительный прогон.
|
||||
7. Повторный запуск должен пропускать неизменившиеся документы.
|
||||
8. Не изменять пользовательские файлы `logs/`, исходный DOCX и несвязанные
|
||||
незакоммиченные изменения.
|
||||
|
||||
## Размещение
|
||||
|
||||
Использовать существующую backend-структуру:
|
||||
|
||||
```text
|
||||
backend/
|
||||
normalization/
|
||||
minjust_cbd.py
|
||||
```
|
||||
|
||||
Результат по умолчанию:
|
||||
|
||||
```text
|
||||
data/minjust-normalized/
|
||||
manifest.sqlite3
|
||||
documents/<document_code>/document.json
|
||||
documents/<document_code>/editions/<edition_code>/edition.json
|
||||
documents/<document_code>/editions/<edition_code>/<lang>/content.html
|
||||
documents/<document_code>/editions/<edition_code>/<lang>/content.txt
|
||||
documents/<document_code>/editions/<edition_code>/<lang>/fragments.json
|
||||
```
|
||||
|
||||
Каталог уже покрывается правилом игнорирования `data/`.
|
||||
|
||||
## Канонические данные
|
||||
|
||||
### `document.json`
|
||||
|
||||
Сохранить как минимум:
|
||||
|
||||
- `schema_version`;
|
||||
- `source_code`;
|
||||
- двуязычные `class`, `type`, `title`, `name`, `status`;
|
||||
- номера и даты без юридически неподтверждённых выводов;
|
||||
- флаги публичности;
|
||||
- органы, публикации, ключевые слова и классификаторы с сохранением дерева;
|
||||
- пути листьев иерархий для будущих фильтров;
|
||||
- ссылки из `References` без выдумывания связей;
|
||||
- `available_languages`;
|
||||
- список редакций;
|
||||
- путь к источнику и SHA-256 исходных файлов;
|
||||
- версию нормализатора и время обработки.
|
||||
|
||||
Пустые строки привести к `null`, но не переводить значения и не заменять
|
||||
официальные формулировки собственными.
|
||||
|
||||
### `edition.json`
|
||||
|
||||
Сохранить:
|
||||
|
||||
- исходный код редакции;
|
||||
- двуязычное название;
|
||||
- исходный тип;
|
||||
- доступные языки;
|
||||
- изображения без бинарных данных;
|
||||
- контрольные суммы источников;
|
||||
- признаки качества.
|
||||
|
||||
Не считать дату из `Name` датой вступления редакции в силу и не назначать
|
||||
актуальную редакцию без подтверждённого правила источника.
|
||||
|
||||
### Языковой вариант
|
||||
|
||||
Для каждого имеющегося `ru.html` или `ky.html` сформировать:
|
||||
|
||||
- `content.html` — безопасный HTML для frontend;
|
||||
- `content.txt` — извлечённый текст с сохранением смысловых переносов;
|
||||
- `fragments.json` — упорядоченные адресуемые блоки.
|
||||
|
||||
Сырой HTML всегда читать как UTF-8, не доверяя его meta charset.
|
||||
|
||||
## Очистка HTML
|
||||
|
||||
- удалить `script`, `style`, `meta`, `link`, комментарии и служебные элементы;
|
||||
- удалить обработчики событий, inline-стили и опасные URL;
|
||||
- разрешить минимальный набор структурных тегов: заголовки, абзацы, `pre`,
|
||||
списки, таблицы, безопасные ссылки, изображения и базовое текстовое
|
||||
выделение;
|
||||
- нормализовать Unicode в NFC;
|
||||
- преобразовать неразрывные пробелы и избыточные пробелы только в
|
||||
`content.txt`, не искажая отображаемый юридический текст;
|
||||
- не загружать внешние ресурсы;
|
||||
- относительные изображения связывать только с файлами внутри редакции;
|
||||
- неизвестную или сломанную разметку сохранять как текст, а не терять молча.
|
||||
|
||||
## Фрагменты
|
||||
|
||||
Минимальная версия должна создавать фрагмент для каждого содержательного
|
||||
блочного элемента. Распознавание статей и пунктов допускается только простыми
|
||||
проверяемыми правилами RU/KY; обычный абзац является fallback.
|
||||
|
||||
Каждый фрагмент содержит:
|
||||
|
||||
- стабильный `id`;
|
||||
- `document_code`, `edition_code`, `language`;
|
||||
- порядковую позицию;
|
||||
- тип блока;
|
||||
- чистый текст;
|
||||
- SHA-256 текста.
|
||||
|
||||
Идентификатор должен быть детерминированным и включать документ, редакцию,
|
||||
язык и позицию. Не добавлять сложное сопоставление фрагментов между
|
||||
редакциями — это отдельная будущая задача.
|
||||
|
||||
## Манифест и возобновление
|
||||
|
||||
SQLite-манифест должен хранить:
|
||||
|
||||
- код документа;
|
||||
- SHA-256 набора исходных файлов;
|
||||
- версию схемы и нормализатора;
|
||||
- время успешной обработки;
|
||||
- состояние и текст последней ошибки.
|
||||
|
||||
Если checksum и версия нормализатора не изменились, документ пропускается.
|
||||
После успешной повторной обработки ошибка удаляется. Добавить `--limit` и
|
||||
понятный терминальный прогресс, пригодный для долгого запуска.
|
||||
|
||||
## CLI и импорт из будущего backend
|
||||
|
||||
CLI запускается из корня репозитория:
|
||||
|
||||
```bash
|
||||
python3 backend/normalization/minjust_cbd.py
|
||||
```
|
||||
|
||||
Предусмотреть параметры:
|
||||
|
||||
- `--input`;
|
||||
- `--output`;
|
||||
- `--limit`;
|
||||
- `--refresh`;
|
||||
- `--log-level`.
|
||||
|
||||
Основную функцию можно импортировать без запуска CLI. Не создавать
|
||||
планировщик, очередь задач или framework-интеграцию.
|
||||
|
||||
## Проверки
|
||||
|
||||
Добавить один компактный тестовый модуль, который проверяет:
|
||||
|
||||
1. русскую редакцию;
|
||||
2. кыргызскую редакцию;
|
||||
3. двуязычную редакцию;
|
||||
4. Word HTML с опасным `script`, inline-стилем и `javascript:` URL;
|
||||
5. документ без HTML;
|
||||
6. повторный запуск и пропуск неизменившегося документа;
|
||||
7. продолжение после ошибки одного документа;
|
||||
8. детерминированные fragment ID и checksums.
|
||||
|
||||
Провести пилотный read-only запуск на небольшой реальной выборке через
|
||||
`--limit`, не нормализовать весь архив в рамках разработки.
|
||||
|
||||
## Документация и версия
|
||||
|
||||
- описать запуск, структуру результата и ограничения в `backend/README.md`;
|
||||
- отметить реализацию в `docs/operations/project-status.md`;
|
||||
- это новая обратно совместимая backend-функция: увеличить minor-версию
|
||||
backend по SemVer;
|
||||
- обновить все отображаемые backend-версии и footer, не меняя версию
|
||||
Telegram-бота;
|
||||
- frontend по-прежнему помечать как не созданный.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
- сырой архив не изменён;
|
||||
- тесты проходят;
|
||||
- `git diff --check` проходит;
|
||||
- пилотный запуск завершается без остановки на отдельных ошибках;
|
||||
- повторный пилотный запуск пропускает неизменившиеся документы;
|
||||
- unsafe HTML не попадает в `content.html`;
|
||||
- каждый фрагмент прослеживается до документа, редакции, языка и исходного
|
||||
файла;
|
||||
- отсутствуют молча потерянные HTML или ошибки;
|
||||
- реализация не содержит PostgreSQL/OpenSearch/RAG-кода «на будущее».
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
47
docs/product/project-overview.md
Normal file
47
docs/product/project-overview.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Обзор проекта
|
||||
|
||||
## Назначение
|
||||
|
||||
Акылдаш — юридическая информационно-аналитическая платформа. Она должна помочь
|
||||
пользователю находить применимые нормы и материалы, понимать их актуальность и
|
||||
получать ответ со ссылками на проверяемые первоисточники. Платформа не заменяет
|
||||
профессиональное юридическое заключение.
|
||||
|
||||
## Основные области
|
||||
|
||||
1. Получение открытых или лицензированных законов, актов и иных правовых
|
||||
источников.
|
||||
2. Очистка, нормализация, связывание редакций и сохранение происхождения данных.
|
||||
3. Хранение структурированных документов и поисковых индексов.
|
||||
4. Формирование базы знаний и RAG с обязательными ссылками на источники.
|
||||
5. Backend для доступа к данным и функциям платформы.
|
||||
6. Frontend для поиска, анализа и работы с результатами.
|
||||
7. Инструменты рабочего окружения команды, включая Telegram-бота.
|
||||
|
||||
Физическая структура каждой области появится вместе с её первой задачей. До
|
||||
этого список служит картой продукта, а не обещанием заранее выбранной
|
||||
архитектуры.
|
||||
|
||||
## Обязательные принципы
|
||||
|
||||
- Источник, дата получения, редакция и лицензия документа должны быть
|
||||
прослеживаемыми.
|
||||
- Ответ системы должен отделять найденный факт от вывода модели и ссылаться на
|
||||
конкретный первоисточник.
|
||||
- Актуальность правовых данных должна проверяться до использования в ответе.
|
||||
- Существенные юридические выводы проверяет человек.
|
||||
- Секреты и персональные данные не попадают в Git, логи и тестовые наборы.
|
||||
- Данные загружаются только при наличии законного основания и с соблюдением
|
||||
условий источника.
|
||||
|
||||
## Правовая защита проекта
|
||||
|
||||
Договоры, заявки, материалы об интеллектуальной собственности и другие
|
||||
закрытые юридические документы следует хранить отдельно от исходного кода: в
|
||||
закрытом репозитории или защищённом документном хранилище. Доступ выдаётся
|
||||
поимённо и только тем, кому он нужен. В основном репозитории можно хранить
|
||||
несекретный реестр документов, правила доступа и ссылки на место хранения.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
128
docs/product/search-catalog-v1.md
Normal file
128
docs/product/search-catalog-v1.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Справочники Search API v1
|
||||
|
||||
Статус: рабочий контракт Search API v1. Тематическая иерархия вынесена в
|
||||
отдельный черновой каталог и не входит в фильтры API v1.
|
||||
|
||||
`code` — неизменяемый идентификатор, независимый от языка. Клиент хранит и
|
||||
передаёт только `code`; русское и кыргызское названия являются подписями и могут
|
||||
исправляться без изменения кода.
|
||||
|
||||
## Типы документов
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `constitution` | Конституция | Конституция |
|
||||
| `constitutional_law` | Конституционный Закон | Конституциалык Мыйзам |
|
||||
| `code` | Кодекс | Кодекс |
|
||||
| `law` | Закон | Мыйзам |
|
||||
| `decree` | Указ | Жарлык |
|
||||
| `resolution` | Постановление | Токтом |
|
||||
| `order` | Распоряжение | Распоряжение |
|
||||
| `instruction` | Инструкция | Инструкция |
|
||||
| `rules` | Правила | Правила |
|
||||
| `procedure` | Порядок | Порядок |
|
||||
| `provision` | Положение | Жобо |
|
||||
| `regulation` | Регламент | Регламент |
|
||||
| `charter` | Устав | Жобо (Устав) |
|
||||
| `program` | Программа | Программа |
|
||||
| `plan` | План | План |
|
||||
| `strategy` | Стратегия | Стратегия |
|
||||
| `concept` | Концепция | Концепция |
|
||||
| `doctrine` | Доктрина | Доктрина |
|
||||
| `agreement` | Соглашение | Соглашение |
|
||||
| `declaration` | Декларация | Декларация |
|
||||
| `registry` | Реестр | Реестр |
|
||||
| `norms` | Нормативы | Нормативы |
|
||||
| `model` | Модель | Модель |
|
||||
| `matrix` | Матрица | Матрица |
|
||||
| `study` | Исследование | Исследование |
|
||||
| `report` | Доклад | Доклад |
|
||||
| `principles` | Основные принципы | Основные принципы |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Статусы
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `active` | Действует | Күчүндө |
|
||||
| `repealed` | Утратил силу | Күчүн жоготту |
|
||||
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
|
||||
|
||||
## Юридическая сила
|
||||
|
||||
Это укрупнённая группировка для фильтра Search API, а не полная иерархия
|
||||
нормативных правовых актов из статьи 6 Закона КР «О нормативных правовых
|
||||
актах». В этой группировке Конституция и конституционные законы относятся к
|
||||
конституционному уровню, кодексы и законы — к законодательному, указы и
|
||||
постановления, перечисленные законом как виды НПА, — к подзаконному.
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `constitutional` | Конституционный уровень | Конституциялык деңгээл |
|
||||
| `legislative` | Законодательный уровень | Мыйзам деңгээли |
|
||||
| `subordinate` | Подзаконный уровень | Мыйзам алдындагы деңгээл |
|
||||
|
||||
Для прочих типов значение не назначается автоматически: одного названия типа
|
||||
недостаточно, чтобы установить юридическую силу утверждающего НПА. Основание:
|
||||
[статьи 4 и 6 Закона КР «О нормативных правовых актах»](https://cbd.minjust.gov.kg/4-3987/edition/53304/ru).
|
||||
Эта группировка реализована как фильтр `legal_force` Search API v1.
|
||||
|
||||
## Органы принятия
|
||||
|
||||
Орган содержит два уровня: стабильную группу и конкретный орган. В v1 группы
|
||||
следующие:
|
||||
|
||||
| Code | RU | KY |
|
||||
| --- | --- | --- |
|
||||
| `president` | Президент | Президент |
|
||||
| `parliament` | Органы законодательной власти | Мыйзам чыгаруу бийлик органдары |
|
||||
| `cabinet` | Правительство и Кабинет Министров | Өкмөт жана Министрлер Кабинети |
|
||||
| `ministries_and_committees` | Министерства и государственные комитеты | Министрликтер жана мамлекеттик комитеттер |
|
||||
| `administrative_agencies` | Административные ведомства | Административдик ведомстволор |
|
||||
| `national_bank` | Национальный банк | Улуттук банк |
|
||||
| `other_state_bodies` | Иные государственные органы | Башка мамлекеттик органдар |
|
||||
| `local_representative_bodies` | Представительные органы местного самоуправления | Жергиликтүү өз алдынча башкаруунун өкүлчүлүктүү органдары |
|
||||
| `other` | Прочие органы | Башка органдар |
|
||||
|
||||
Конкретные министерства, муниципальные и айылные кенеши не получают ID из
|
||||
подписи: в выгрузке ЦБД их поле `Code` часто равно `null`. До появления
|
||||
первичного неизменяемого идентификатора API v1 выдаёт и принимает только код
|
||||
группы органа. Полный каталог конкретных органов — отдельная версия (`v2`),
|
||||
когда источник предоставит такие идентификаторы либо будет утверждён вручную
|
||||
поддерживаемый реестр.
|
||||
|
||||
## Темы
|
||||
|
||||
Полная исходная иерархия `GeneralClassifiers` сохранена отдельно в
|
||||
[`search-topic-taxonomy-v1.json`](search-topic-taxonomy-v1.json). Над исходными
|
||||
ветвями добавлены предложенные смысловые группы; исходные подписи и вложенность
|
||||
не переставляются. Поле `document_count` показывает число документов, где узел
|
||||
встречается. Предлагаются группы: конституционный строй и права; государство и
|
||||
публичное управление; право, суд и правопорядок; международные отношения;
|
||||
оборона и безопасность; экономика, финансы и предпринимательство; труд и
|
||||
социальная защита; здравоохранение; образование, наука и культура; земля,
|
||||
природные ресурсы и окружающая среда; сельское хозяйство; транспорт, связь,
|
||||
строительство и жильё; информация и СМИ; гражданские, семейные и жилищные
|
||||
отношения; общие и межотраслевые вопросы.
|
||||
|
||||
Каталог является черновиком: группа корневой рубрики предложена по русской
|
||||
подписи, рубрики с несколькими совпадениями правил или без совпадений помещены
|
||||
в `review_required` и отмечены `manual_review`; остальные помечены
|
||||
`provisional_lexical`. В корпусе 2 065 узлов и 687 вариантов корневых рубрик;
|
||||
328 корневых вариантов ожидают ручной классификации. Отсутствующие в источнике
|
||||
кыргызские подписи оставлены пустыми: они отсутствуют у 1 524 узлов, новые
|
||||
переводы не придуманы. Внутренние `source-*` ID являются
|
||||
временными хешами кода источника и цепочки подписей; при `Code: null` они
|
||||
зависят от подписей и не утверждаются как публичные стабильные коды.
|
||||
|
||||
Тематическая навигация и фильтр не входят в Search API v1. До включения в API
|
||||
нужно вручную утвердить смысловые группы, идентификаторы и недостающие KY-
|
||||
подписи. Источник — нормализованные данные ЦБД Минюста; команда для пересборки
|
||||
каталога находится в `tools/build_search_topic_taxonomy.py`.
|
||||
|
||||
## Правила совместимости
|
||||
|
||||
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
|
||||
- Новое значение добавляется только новой записью; удалённое остаётся доступно
|
||||
для старых документов.
|
||||
- Запрос с неизвестным кодом возвращает `400`.
|
||||
179
docs/product/search-relevance-review-interface-plan.md
Normal file
179
docs/product/search-relevance-review-interface-plan.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# План внутреннего интерфейса оценки поисковой выдачи
|
||||
|
||||
## Цель
|
||||
|
||||
Создать закрытую лабораторию релевантности: юрист оценивает фактическую выдачу
|
||||
нашего OpenSearch по практическим запросам. Цель — улучшать собственное
|
||||
ранжирование, а не воспроизводить алгоритм сайта Минюста КР.
|
||||
|
||||
ЦБД Минюста используется как официальный источник текста, реквизитов, статуса
|
||||
и редакции акта. Порядок результатов и оценка их полезности определяются в
|
||||
нашем сервисе.
|
||||
|
||||
Это внутренний рабочий инструмент, а не публичный frontend MVP. Он не включает
|
||||
регистрацию, личные кабинеты, публичный дизайн, сложные фильтры или сравнение
|
||||
редакций.
|
||||
|
||||
## Сценарий юриста
|
||||
|
||||
1. Указать поисковый запрос и язык.
|
||||
2. Получить первые 10–20 результатов в точном порядке OpenSearch.
|
||||
3. Увидеть позицию каждого результата: `#1`, `#2` и далее.
|
||||
4. Открыть выбранный документ по клику на название в правой панели страницы.
|
||||
5. Поставить каждому просмотренному результату оценку и комментарий.
|
||||
6. Сохранить снимок выдачи и оценок.
|
||||
7. Передать накопленные записи на анализ ранжирования.
|
||||
|
||||
## Оценка результата
|
||||
|
||||
| Балл | Значение |
|
||||
| ---: | --- |
|
||||
| 0 | Нерелевантен: совпали слова, но акт не отвечает на вопрос. |
|
||||
| 1 | Косвенно полезен: относится к теме, но прямого ответа нет. |
|
||||
| 2 | Частично полезен: отвечает не полностью или требует другого акта. |
|
||||
| 3 | Прямо и достаточно отвечает на запрос. |
|
||||
|
||||
Оценка относится к отдельному документу, а не ко всей выдаче. Результат без
|
||||
оценки считается непросмотренным, а не нерелевантным.
|
||||
|
||||
## Интерфейс
|
||||
|
||||
Рекомендуемый экран — две панели.
|
||||
|
||||
- Вверху: поле запроса, переключатель RU/KY, выбор числа результатов и кнопка
|
||||
«Найти».
|
||||
- Слева: карточки результатов в порядке выдачи. Карточка содержит позицию,
|
||||
название, тип, статус, дату, номер и фрагмент текста.
|
||||
- Справа: заголовок, реквизиты, очищенный HTML выбранной редакции и ссылка на
|
||||
официальный источник.
|
||||
- В карточке: кнопки оценки `0`, `1`, `2`, `3` и раскрываемое поле
|
||||
комментария.
|
||||
- Внизу: имя или псевдоним проверяющего, общий комментарий и кнопка
|
||||
«Сохранить оценку».
|
||||
|
||||
Правая панель предпочтительнее popup: она не блокируется браузером, сохраняет
|
||||
контекст выдачи и работает на одном экране с оценкой.
|
||||
|
||||
### Доступность оценки и документа
|
||||
|
||||
Оценка реализуется нативной группой `radio` внутри `fieldset` с `legend`
|
||||
«Оценка результата». У каждого значения есть видимая подпись: «0 —
|
||||
нерелевантен», «1 — косвенно полезен», «2 — частично полезен», «3 — прямо
|
||||
отвечает». Нельзя передавать смысл оценки только цветом. Все элементы управления
|
||||
доступны с клавиатуры, имеют видимый `:focus-visible`; выбранный результат
|
||||
обозначается текстом и визуальным состоянием.
|
||||
|
||||
На узком экране список результатов занимает всю страницу. Кнопка «Открыть
|
||||
документ» открывает полноэкранный нативный `<dialog>` с явной кнопкой
|
||||
«Закрыть». При закрытии фокус возвращается на исходную кнопку «Открыть
|
||||
документ».
|
||||
|
||||
### Состояния
|
||||
|
||||
- Во время поиска и сохранения показывается состояние загрузки; повторная
|
||||
отправка на это время недоступна. Стабильная пустая область `role="status"`
|
||||
в DOM объявляет начало поиска, число результатов и успешное сохранение.
|
||||
- Пустая выдача сообщает: «По запросу „…“ ничего не найдено» и предлагает
|
||||
«Изменить запрос».
|
||||
- Ошибка поиска сообщает причину и предлагает «Повторить поиск»; текст ошибки
|
||||
выводится в `role="alert"`.
|
||||
- Ошибка сохранения сообщает: «Не удалось сохранить. Проверьте подключение и
|
||||
повторите». Черновик остаётся в браузере, текст ошибки выводится в
|
||||
`role="alert"`.
|
||||
- После сохранения выводится: «Оценка сохранена · № … · дата и время» в
|
||||
указанной стабильной области `role="status"`.
|
||||
|
||||
До сохранения черновик хранится в `localStorage`. После успешного сохранения
|
||||
интерфейс показывает ID записи и время сохранения.
|
||||
|
||||
## Backend и хранение
|
||||
|
||||
Использовать существующие endpoint:
|
||||
|
||||
- `GET /search` — получить ранжированный список результатов;
|
||||
- `GET /documents/{code}/editions/{edition}` — получить текст выбранной
|
||||
редакции.
|
||||
|
||||
Добавить два endpoint:
|
||||
|
||||
- `POST /search-reviews` — валидирует и сохраняет оценку;
|
||||
- `GET /search-reviews/export` — отдаёт накопленные записи в JSON.
|
||||
|
||||
Для первой версии достаточно отдельной SQLite-базы. Это стандартная библиотека
|
||||
Python, данные переживают перезапуск и легко выгружаются для анализа. Доступ к
|
||||
интерфейсу и всем endpoint `search-reviews`, включая экспорт, должен быть
|
||||
ограничен локальной сетью/VPN или аутентификацией reverse proxy.
|
||||
|
||||
Одна запись представляет один сохранённый поисковый сеанс:
|
||||
|
||||
| Поле | Назначение |
|
||||
| --- | --- |
|
||||
| `id`, `created_at` | Идентификатор и время сохранения. |
|
||||
| `reviewer` | Имя или псевдоним проверяющего. |
|
||||
| `query`, `language` | Исходный запрос и язык поиска. |
|
||||
| `index_name`, `algorithm_version` | Версия индекса и алгоритма на момент оценки. |
|
||||
| `top_result_code` | Код документа в позиции `#1`. |
|
||||
| `results_json` | Снимок результатов в исходном порядке с оценками и комментариями. |
|
||||
| `overall_comment` | Общий комментарий к выдаче. |
|
||||
|
||||
В `results_json` для каждого результата сохраняются: `rank`, `document_code`,
|
||||
`edition_code`, название, реквизиты, фрагмент, оценка и комментарий. Снимок
|
||||
выдачи обязателен: после изменения алгоритма можно будет восстановить именно
|
||||
тот результат, который видел юрист.
|
||||
|
||||
## Правила сохранения
|
||||
|
||||
- Запрос не пустой, не длиннее 500 символов.
|
||||
- Оценка может быть только целым числом от 0 до 3 либо отсутствовать у
|
||||
непросмотренного результата.
|
||||
- Комментарии имеют ограничение длины; пользовательские значения не вставляются
|
||||
в HTML.
|
||||
- `GET /search` возвращает краткоживущий HMAC-подписанный снимок выдачи:
|
||||
запрос, язык, индекс, результаты и их позиции. `POST /search-reviews`
|
||||
принимает этот снимок и только оценки с комментариями. Сервер проверяет
|
||||
подпись и срок, самостоятельно формирует `results_json` и отклоняет оценки
|
||||
для отсутствующих либо подменённых позиций и документов.
|
||||
- Нельзя передавать персональные данные или закрытые материалы в комментариях.
|
||||
- Кнопка «Сохранить оценку» остаётся доступной до отправки: отсутствующие
|
||||
обязательные поля проверяются после нажатия, ошибка показана рядом с полем и
|
||||
фокус переводится на первое некорректное поле.
|
||||
|
||||
## Анализ данных
|
||||
|
||||
Экспорт должен содержать исходный JSON-снимок, чтобы его можно было обработать
|
||||
скриптом или открыть в табличном инструменте. Первый отчёт строит:
|
||||
|
||||
- среднюю оценку для каждой позиции выдачи;
|
||||
- долю результатов с оценкой `3` в top-1, top-3 и top-10;
|
||||
- запросы, где нет результатов с оценкой `2` или `3`;
|
||||
- документы, которые часто получают низкую оценку в первых позициях;
|
||||
- комментарии для ручного разбора ошибок.
|
||||
|
||||
Сырые оценки не должны автоматически менять веса поиска. Сначала команда
|
||||
разбирает причины: анализатор, синонимы, статус, отсутствие документа,
|
||||
неправильная формулировка запроса или юридическая неоднозначность.
|
||||
|
||||
## Этапы реализации
|
||||
|
||||
1. Утвердить шкалу 0–3, обязательность имени проверяющего и правила доступа.
|
||||
2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт.
|
||||
3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без
|
||||
Next.js и отдельного публичного приложения.
|
||||
4. Подключить поиск, правую панель документа, черновик, адаптивный режим и
|
||||
сохранение в закреплённой панели действий на широком экране.
|
||||
5. Добавить минимальные backend-проверки сохранения, повторного запуска,
|
||||
экспорта и недопустимых оценок.
|
||||
6. Провести ручный прогон на десяти русскоязычных практических запросах с
|
||||
двумя юристами.
|
||||
7. На собранных записях настроить OpenSearch и повторить тот же набор запросов.
|
||||
|
||||
## Критерий готовности
|
||||
|
||||
Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет
|
||||
оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ
|
||||
на позиции `#1`, полный порядок результатов, оценки, комментарии и версию
|
||||
индекса. Данные можно сравнить до и после изменения алгоритма.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
21811
docs/product/search-topic-taxonomy-v1.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -180,4 +180,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш v0.2.1
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
|
||||
BIN
docs/Функциональные_возможности_поисковой_СПС.docx
Normal file
BIN
docs/Функциональные_возможности_поисковой_СПС.docx
Normal file
Binary file not shown.
197
tools/build_search_topic_taxonomy.py
Normal file
197
tools/build_search_topic_taxonomy.py
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a draft grouped view of the source GeneralClassifiers hierarchy."""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import unicodedata
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
GROUPS = {
|
||||
"constitutional_order": ("Конституционный строй и права", "Конституциялык түзүлүш жана укуктар"),
|
||||
"government": ("Государство и публичное управление", "Мамлекет жана мамлекеттик башкаруу"),
|
||||
"justice": ("Право, суд и правопорядок", "Укук, сот жана укук тартиби"),
|
||||
"international": ("Международные отношения", "Эл аралык мамилелер"),
|
||||
"security": ("Оборона и безопасность", "Коргонуу жана коопсуздук"),
|
||||
"economy": ("Экономика, финансы и предпринимательство", "Экономика, финансы жана ишкердик"),
|
||||
"labor_social": ("Труд и социальная защита", "Эмгек жана социалдык коргоо"),
|
||||
"health": ("Здравоохранение", "Саламаттыкты сактоо"),
|
||||
"education_culture": ("Образование, наука и культура", "Билим берүү, илим жана маданият"),
|
||||
"land_environment": ("Земля, природные ресурсы и окружающая среда", "Жер, жаратылыш ресурстары жана айлана-чөйрө"),
|
||||
"agriculture": ("Сельское хозяйство", "Айыл чарба"),
|
||||
"infrastructure": ("Транспорт, связь, строительство и жильё", "Транспорт, байланыш, курулуш жана турак жай"),
|
||||
"information": ("Информация и средства массовой информации", "Маалымат жана жалпыга маалымдоо каражаттары"),
|
||||
"civil_family": ("Гражданские, семейные и жилищные отношения", "Жарандык, үй-бүлөлүк жана турак жай мамилелери"),
|
||||
"cross_cutting": ("Общие и межотраслевые вопросы", "Жалпы жана тармактар аралык маселелер"),
|
||||
"review_required": ("Требуется смысловая проверка", "Маанисин текшерүү талап кылынат"),
|
||||
}
|
||||
|
||||
RULES = [
|
||||
("international", r"международ|внешн(яя|ей) полит|дипломат|консул|иностранн|снг|содружеств|эл аралык"),
|
||||
("security", r"оборон|военн|арм|мобилизац|государственн(ая|ой) безопасност|чрезвыча|гражданск(ая|ой) оборон|погранич"),
|
||||
("health", r"здравоохран|медицин|лекарств|санитар|эпидеми|трансплантац|донорств|охрана здоровья"),
|
||||
("education_culture", r"образован|просвещен|наук|культур|искусств|(?<![а-я])спорт|туризм|библиотек|музе|архив|издательств"),
|
||||
("labor_social", r"труд|занятост|пенси|социальн|соцстрах|безработ|инвалид|пособи|охрана труда|семейн(ая|ые) поддержк"),
|
||||
("land_environment", r"земл|природн(ые|ых) ресурс|окружающ|охрана природы|недр|водн(ые|ых) ресурс|атмосферн|животн(ый|ого) мир|лесн(ой|ое) хозяйств|эколог"),
|
||||
("agriculture", r"сельск|аграр|растениевод|животновод|земледел|рыболов|рыбовод|зерновод|семеновод|ветеринар"),
|
||||
("infrastructure", r"транспорт|связи|дорог|железнодорож|авиац|строительств|градостро|жилищ|коммунальн|энергетик|почтов|телекоммуникац"),
|
||||
("information", r"информац|средств массовой информац|радио|телевиден|персональн(ые|ых) данные|средства массовой информации"),
|
||||
("economy", r"финанс|бюджет|налог|кредит|эконом|предприним|хозяйственн|промышлен|торгов|тамож|внешнеэконом|банк|страхован|инвестиц|ценн(ые|ых) бумаг|лицензировани|валют|конкуренц|монопол|государственн(ая|ой) собственност"),
|
||||
("justice", r"(?<![а-я])суд(?![а-я])|юстици|прокуратур|адвокат|нотариат|уголов|правонаруш|правопоряд|общественн(ого|ый) поряд|административн(ой|ая) ответственност|исполнени(е|я) наказан|следств|розыск|правоохран|систематизац.*норматив|законодательств о нормативн|арбитражн(ый|ого) процесс"),
|
||||
("constitutional_order", r"конституц|государственн(ого|ый) стро|права и свобод|гражданств|выбор|референдум|парламент|жогорку кенеш|избирательн|символ|государственн(ый|ого) язык|законодательная деятельность"),
|
||||
("government", r"государственн(ая|ой) служ|государственн(ое|ого) управлен|местн(ое|ого) самоуправлен|местная государственная администрация|орган(ы|ов) государственн|административн(ое|ого) управлен|государственн(ая|ой) власть|муниципальн|территориальн|государственн(ая|ой) наград|государственн(ый|ого) архив|министерств|кабинет министров|правительство|государственн(ого|ая) аппарата|нормотворческая деятельность|решения по кадровым|назначение на должность|освобождение от должности"),
|
||||
("civil_family", r"гражданск|семейн|брачн|жилищ|собственност|наследован|договор|обязательств|авторск|интеллектуальн|потребител|личн(ые|ых) неимущественн|опек|попечительств"),
|
||||
("cross_cutting", r"общ(ие|им) вопрос|межотрасл|общие положения|классификатор|учёт норматив|учет норматив|праздник|увековеч|по другим вопросам|^законодательство$"),
|
||||
]
|
||||
|
||||
|
||||
def clean(value):
|
||||
return unicodedata.normalize("NFC", value.strip()) if isinstance(value, str) and value.strip() else None
|
||||
|
||||
|
||||
def group_for(label):
|
||||
text = (label or "").casefold()
|
||||
matches = [code for code, pattern in RULES if re.search(pattern, text)]
|
||||
return matches[0] if len(matches) == 1 else "review_required"
|
||||
|
||||
|
||||
def node_id(path):
|
||||
raw = json.dumps(path, ensure_ascii=False, separators=(",", ":"))
|
||||
return "source-" + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def add_nodes(nodes, classifiers, parent_path, seen, document_code):
|
||||
if classifiers is None:
|
||||
return
|
||||
if not isinstance(classifiers, list):
|
||||
raise RuntimeError(f"normalized document {document_code} contains a non-list GeneralClassifiers branch")
|
||||
for source in classifiers:
|
||||
if not isinstance(source, dict):
|
||||
raise RuntimeError(f"normalized document {document_code} contains a non-object GeneralClassifiers node")
|
||||
name = source.get("Name") if isinstance(source.get("Name"), dict) else {}
|
||||
labels = {"ru": clean(name.get("Rus")), "ky": clean(name.get("Kyr"))}
|
||||
code = source.get("Code")
|
||||
identity = (str(code) if code is not None else None, labels["ru"], labels["ky"])
|
||||
path = parent_path + [identity]
|
||||
identifier = node_id(path)
|
||||
if identifier not in nodes:
|
||||
nodes[identifier] = {
|
||||
"id": identifier,
|
||||
"source_codes": set(),
|
||||
"labels": labels,
|
||||
"document_count": 0,
|
||||
"children": {},
|
||||
"_path": path,
|
||||
}
|
||||
node = nodes[identifier]
|
||||
if code is not None:
|
||||
node["source_codes"].add(str(code))
|
||||
if identifier not in seen:
|
||||
node["document_count"] += 1
|
||||
seen.add(identifier)
|
||||
add_nodes(nodes, source.get("GeneralClassifiers"), path, seen, document_code)
|
||||
|
||||
|
||||
def emit_node(node, children):
|
||||
result = {
|
||||
"id": node["id"],
|
||||
"source_codes": sorted(node["source_codes"]),
|
||||
"labels": node["labels"],
|
||||
"document_count": node["document_count"],
|
||||
"children": children,
|
||||
}
|
||||
if "grouping_status" in node:
|
||||
result["grouping_status"] = node["grouping_status"]
|
||||
return result
|
||||
|
||||
|
||||
def build(normalized_root):
|
||||
db_path = normalized_root / "manifest.sqlite3"
|
||||
with closing(sqlite3.connect(db_path)) as connection:
|
||||
codes = [row[0] for row in connection.execute("SELECT code FROM documents WHERE state='success' ORDER BY code")]
|
||||
nodes = {}
|
||||
roots = {}
|
||||
source_root = normalized_root / "documents"
|
||||
processed = 0
|
||||
|
||||
def read(code):
|
||||
path = source_root / code / "document.json"
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError(f"cannot read normalized document {code}: {error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise RuntimeError(f"normalized document {code} must contain a JSON object")
|
||||
classifiers = data.get("general_classifiers")
|
||||
if not isinstance(classifiers, list):
|
||||
raise RuntimeError(f"normalized document {code} must contain a general_classifiers list")
|
||||
return code, classifiers
|
||||
|
||||
with ThreadPoolExecutor(max_workers=24) as pool:
|
||||
for offset in range(0, len(codes), 512):
|
||||
for code, classifiers in pool.map(read, codes[offset:offset + 512]):
|
||||
seen = set()
|
||||
add_nodes(nodes, classifiers, [], seen, code)
|
||||
for source in classifiers if isinstance(classifiers, list) else []:
|
||||
if not isinstance(source, dict):
|
||||
continue
|
||||
name = source.get("Name") if isinstance(source.get("Name"), dict) else {}
|
||||
labels = {"ru": clean(name.get("Rus")), "ky": clean(name.get("Kyr"))}
|
||||
identity = (str(source.get("Code")) if source.get("Code") is not None else None, labels["ru"], labels["ky"])
|
||||
key = node_id([identity])
|
||||
roots.setdefault(key, nodes[key])
|
||||
processed += 1
|
||||
|
||||
by_parent = {}
|
||||
for node in nodes.values():
|
||||
parent = node["_path"][:-1]
|
||||
parent_id = node_id(parent) if parent else None
|
||||
by_parent.setdefault(parent_id, []).append(node)
|
||||
|
||||
groups = {code: {"code": code, "labels": {"ru": names[0], "ky": names[1]}, "source_roots": []} for code, names in GROUPS.items()}
|
||||
for root in roots.values():
|
||||
code = group_for(root["labels"]["ru"])
|
||||
root["grouping_status"] = "manual_review" if code == "review_required" else "provisional_lexical"
|
||||
groups[code]["source_roots"].append(root)
|
||||
|
||||
def render(node):
|
||||
children = sorted(by_parent.get(node["id"], []), key=lambda item: (item["labels"]["ru"] or "", item["labels"]["ky"] or "", item["id"]))
|
||||
return emit_node(node, [render(child) for child in children])
|
||||
|
||||
missing_ky_count = sum(not node["labels"]["ky"] for node in nodes.values())
|
||||
assigned_roots = [root["id"] for group in groups.values() for root in group["source_roots"]]
|
||||
assert len(assigned_roots) == len(roots) == len(set(assigned_roots))
|
||||
catalog = {
|
||||
"status": "draft; semantic group assignment requires review",
|
||||
"source": "Ministry of Justice GeneralClassifiers from normalized documents",
|
||||
"source_document_count": processed,
|
||||
"source_node_count": len(nodes),
|
||||
"source_root_count": len(roots),
|
||||
"nodes_missing_kyrgyz_label": missing_ky_count,
|
||||
"id_policy": "provisional SHA-256 of source code and bilingual ancestor labels; null source codes are common",
|
||||
"groups": [
|
||||
{"code": code, "labels": group["labels"], "source_root_count": len(group["source_roots"]), "children": [render(root) for root in sorted(group["source_roots"], key=lambda item: (item["labels"]["ru"] or "", item["labels"]["ky"] or "", item["id"]))]}
|
||||
for code, group in groups.items()
|
||||
],
|
||||
}
|
||||
return catalog
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("normalized_root", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
catalog = build(args.normalized_root)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(catalog, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"documents={catalog['source_document_count']} nodes={catalog['source_node_count']} roots={catalog['source_root_count']} groups={len(catalog['groups'])} missing_ky={catalog['nodes_missing_kyrgyz_label']} review_roots={next(group['source_root_count'] for group in catalog['groups'] if group['code'] == 'review_required')}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
51
tools/telegram-bot/README.md
Normal file
51
tools/telegram-bot/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Telegram-бот Акылдаш
|
||||
|
||||
Версия: `0.2.2`
|
||||
|
||||
Бот сохраняет сообщения разрешённых тем Telegram в локальную SQLite-базу и
|
||||
выгружает обсуждения в Markdown. Внешние Python-зависимости не требуются.
|
||||
|
||||
## Запуск
|
||||
|
||||
Требуется Python 3.11 или новее.
|
||||
|
||||
```bash
|
||||
cd tools/telegram-bot
|
||||
export TELEGRAM_BOT_TOKEN='...'
|
||||
export TELEGRAM_CHAT_ID='-1004242041275'
|
||||
export TELEGRAM_OWNER_ID='87262245'
|
||||
export TELEGRAM_REPORT_THREAD_ID='37'
|
||||
export TELEGRAM_ALLOWED_THREAD_IDS='0,2,4,6,8'
|
||||
python3 bot.py
|
||||
```
|
||||
|
||||
Доступные команды: `/help`, `/status`, `/export`. Экспорт доступен только
|
||||
пользователю с Telegram ID из `TELEGRAM_OWNER_ID`. Первый `/export` выгружает
|
||||
всю сохранённую тему, последующие — сообщения после предыдущей успешно
|
||||
созданной отсечки.
|
||||
|
||||
Markdown публикуется в теме `TELEGRAM_REPORT_THREAD_ID`, а в исходной теме
|
||||
остаётся отсечка со ссылкой и общим хэштегом отчёта.
|
||||
|
||||
База по умолчанию хранится в `data/secretary.sqlite3`. Сообщения из других
|
||||
групп и тем не сохраняются. Полный ответ Telegram сохраняется в базе, поэтому
|
||||
метаданные вложений остаются доступными для последующего скачивания.
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v
|
||||
```
|
||||
|
||||
## Synology
|
||||
|
||||
Контейнер `akyldash-bot` работает на образе `python:3.11-slim` через закрытый
|
||||
прокси-контейнер, с политикой перезапуска `unless-stopped`. Постоянные данные
|
||||
находятся в `/volume1/docker/akyldash/data`.
|
||||
|
||||
При обновлении развёртывания файл `tools/telegram-bot/bot.py` копируется в
|
||||
каталог контейнера как `bot.py`.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан
|
||||
@@ -18,7 +18,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
APP_NAME = "Акылдаш"
|
||||
APP_VERSION = "0.2.1"
|
||||
APP_VERSION = "0.2.2"
|
||||
FOOTER = f"{APP_NAME} v{APP_VERSION}"
|
||||
|
||||
|
||||
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
BIN
План_выгрузки_документов_ЦБД_Минюста_КР_v0.1.0.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user