Compare commits

...

90 Commits

Author SHA1 Message Date
7e07f3ab8d docs: record internal search review workflow 2026-09-12 08:54:32 +03:00
70c70fb7dd docs: clarify relevance set validation workflow 2026-09-12 08:52:55 +03:00
e18d477852 docs: align relevance review with internal search 2026-09-12 08:49:38 +03:00
062aaa0074 docs: remove stale review example reference 2026-09-06 23:41:56 +03:00
470745fce3 docs: record project status and next steps 2026-09-06 23:37:40 +03:00
b2274fee6f Merge pull request 'fix: harden production image builds' (#29) from fix/production-build-safety into main
Reviewed-on: #29
2026-08-28 20:09:01 +00:00
abd244fefb fix: include search index mapping in builds 2026-08-28 22:51:12 +03:00
8302c6b782 fix: harden production image builds 2026-08-28 08:59:57 +03:00
bbf8e7a9c8 Merge pull request 'feat: add production deployment baseline' (#28) from feature/production-deployment-baseline into main
Reviewed-on: #28
2026-08-28 05:58:55 +00:00
bf0b6ff070 feat: add production deployment baseline 2026-08-28 08:56:12 +03:00
b91bc98611 Merge pull request 'feat: persist search review drafts' (#27) from feature/search-review-drafts into main
Reviewed-on: #27
2026-08-28 05:13:08 +00:00
4210c2493d fix: persist all review fields 2026-08-28 07:31:28 +03:00
711c9e525e feat: persist search review drafts 2026-08-28 07:29:58 +03:00
808ac6c792 Merge pull request 'feat: add search relevance review lab' (#26) from feature/search-review-lab into main
Reviewed-on: #26
2026-08-28 04:22:46 +00:00
73eb938c03 docs: record search review lab 2026-08-27 08:28:08 +03:00
cccae446ae fix: version search ranking snapshots 2026-08-27 08:27:23 +03:00
24b3a2d422 fix: make review snapshots reproducible 2026-08-27 08:26:25 +03:00
f472a17897 feat: add search relevance review lab 2026-08-27 08:21:59 +03:00
182b87fff2 Merge pull request 'docs: define accessible review interface' (#25) from docs/search-review-accessibility into main
Reviewed-on: #25
2026-08-26 21:11:34 +00:00
407dd2dc09 docs: record accessible review interface 2026-08-27 00:02:29 +03:00
a594aec912 fix: clarify review interface accessibility 2026-08-27 00:01:34 +03:00
c5f651bdae docs: define accessible review interface 2026-08-26 23:59:43 +03:00
f4cd453088 Merge pull request 'docs: plan search review interface' (#24) from docs/search-review-interface-plan into main
Reviewed-on: #24
2026-08-26 20:31:48 +00:00
57e4645a11 docs: record search review plan 2026-08-26 23:29:30 +03:00
9b28616cf4 docs: secure review interface plan 2026-08-26 23:28:43 +03:00
e9da5a6ccc docs: plan search review interface 2026-08-26 23:27:14 +03:00
cb58e91d6b docs: record legal review guide 2026-08-26 01:06:57 +03:00
39896c74de fix: remove placeholder link from legal guide 2026-08-26 01:06:02 +03:00
f3f564ab08 docs: add legal review guide 2026-08-26 01:03:34 +03:00
ccbb2f0944 fix: rebuild legal review workbooks 2026-08-26 00:39:14 +03:00
d468c869c0 docs: describe legal review workbooks 2026-08-26 00:31:28 +03:00
47e03bbc4d fix: neutralize legal review example 2026-08-26 00:30:27 +03:00
eafc06f0bc docs: add language-specific legal review workbooks 2026-08-26 00:28:34 +03:00
9dcbcad9aa docs: record legal review form 2026-08-25 13:37:15 +03:00
028062bd14 docs: add legal relevance review form 2026-08-25 13:35:41 +03:00
bb69ea54b7 Merge pull request 'feat: add versioned search API contract' (#17) from feature/search-api-contract into main
Reviewed-on: #17
2026-08-25 05:35:54 +00:00
0f309f34fa docs: update changelog for search API 2026-08-25 08:32:21 +03:00
2ef33c5cc3 fix: use stable search catalog codes 2026-08-25 08:26:48 +03:00
0ef6ad4d20 docs: define search catalogs v1 2026-08-25 08:22:07 +03:00
bc3951449a docs: align backend version 2026-08-25 07:35:13 +03:00
9b629d93e3 fix: complete search API contract 2026-08-25 07:29:06 +03:00
0bef90debd fix: restrict search to current editions 2026-08-21 07:24:16 +03:00
73ff287b20 feat: add search API contract 2026-08-21 06:12:47 +03:00
8f47dbb4f0 Merge pull request 'feat: atomically switch active search index alias' (#16) from feature/opensearch-alias-switch into main
Reviewed-on: #16
2026-08-21 03:06:45 +00:00
1ad2e4b256 docs: record index alias switch 2026-08-21 00:19:38 +03:00
61681cde73 fix: validate search alias checkpoints 2026-08-21 00:18:28 +03:00
9781e93eaf fix: preserve alias in search load checkpoint 2026-08-21 00:15:24 +03:00
601c7f085f feat: switch search index alias atomically 2026-08-21 00:11:57 +03:00
69d4090f6d Merge pull request 'Добавить план готовности к проектированию frontend' (#15) from docs/frontend-design-readiness into main
Reviewed-on: #15
2026-08-20 20:58:12 +00:00
85672dc041 docs: record frontend readiness roadmap 2026-08-20 23:25:12 +03:00
0f6f12e0e9 docs: add frontend design readiness plan 2026-08-20 23:23:19 +03:00
7a656cfc1a Merge pull request 'Исправить выдачу для открытия ОсОО и ЖЧК' (#14) from fix/company-registration-search into main
Reviewed-on: #14
2026-08-20 20:17:17 +00:00
72de364f29 docs: update changelog for search fix 2026-08-20 15:13:20 +03:00
74873451ab fix: match explicit company registration intents 2026-08-20 15:12:07 +03:00
2faac7b74e fix: narrow company registration intent 2026-08-20 15:10:32 +03:00
e3f08426d5 fix: expose curated search query 2026-08-20 15:08:42 +03:00
dc5399de0a fix: rank company registration queries 2026-08-20 15:04:55 +03:00
8f0f0e3fbf Merge pull request 'Улучшить ранжирование поиска по relevance set' (#13) from feature/search-relevance-ranking into main
Reviewed-on: #13
2026-08-18 08:35:10 +00:00
9ef8b099df docs: update changelog 2026-08-18 08:56:18 +03:00
9318474a54 docs: preserve status history 2026-08-18 08:55:12 +03:00
d701f714e1 fix: improve search relevance ranking 2026-08-18 08:51:22 +03:00
575f4fa3af Merge pull request 'Добавить инструкцию по разметке relevance set' (#12) from docs/relevance-annotation-guide into main
Reviewed-on: #12
2026-08-16 06:21:36 +00:00
e6c4848a7f docs: update changelog 2026-08-16 09:13:05 +03:00
0a72d0d242 docs: add relevance annotation guide 2026-08-16 09:10:36 +03:00
d4ee8fac08 Merge pull request 'Добавить baseline оценки релевантности поиска' (#11) from feature/search-relevance-baseline into main
Reviewed-on: #11
2026-08-15 04:04:55 +00:00
2d31e02753 docs: add project changelog 2026-08-15 00:17:59 +03:00
8d4725231e docs: clarify relevance review 2026-08-14 23:40:26 +03:00
424cc16497 feat: add search relevance baseline 2026-08-14 23:37:52 +03:00
38739543f4 Merge pull request 'Добавить локальный OpenSearch и возобновляемую загрузку' (#10) from feature/local-opensearch-lab into main
Reviewed-on: #10
2026-08-14 15:51:33 +00:00
3a4450fcfe feat: add resumable local OpenSearch loading 2026-08-14 18:32:23 +03:00
dddb07f393 Merge pull request 'Подготовить индекс фрагментов OpenSearch' (#9) from feature/opensearch-index-foundation into main
Reviewed-on: #9
2026-08-13 05:46:46 +00:00
ac5ed95f3a feat: prepare OpenSearch fragment index 2026-08-13 00:39:37 +03:00
30065925e4 Merge pull request 'Добавить нормализацию документов ЦБД Минюста КР' (#8) from feature/minjust-document-normalization into main
Reviewed-on: #8
2026-08-12 06:54:18 +00:00
752bd38c9b fix: harden document normalization
Reject overlapping source and destination roots before any write so normalization cannot replace the raw Ministry archive.

Recover an interrupted directory publication before resume checks and require the normalized target to exist before skipping a manifest success.

Treat malformed link and image URLs as unsafe attributes, add regressions for all review findings, and bump the backend version to 0.2.2.
2026-08-12 09:49:40 +03:00
07381ad70f feat: normalize Ministry of Justice documents
Add a resumable standard-library normalization pipeline for the downloaded CBD archive. It produces canonical bilingual metadata, sanitized HTML, plain text, deterministic fragments, checksums, quality markers, and an SQLite processing manifest while preserving the raw source.

Recover document-list pagination when the Ministry API exhausts request retries, and cover that scenario with a regression test.

Document the normalization workflow and frontend-search MVP plan, include the source functional specification, ignore local runtime logs, and bump the backend version to 0.2.1.
2026-08-12 08:32:12 +03:00
35cae8bcbe Merge pull request 'Добавить синхронизацию документов ЦБД Минюста КР' (#7) from feature/cbd-document-sync into main
Reviewed-on: #7
2026-08-06 20:49:42 +00:00
efa55fe74b fix: recover expired Ministry document lists 2026-08-06 23:48:26 +03:00
0eedcfcfbd feat: add Ministry of Justice document sync 2026-08-06 08:25:14 +03:00
1d398ebb00 Merge pull request 'Перестроить репозиторий под юридическую платформу' (#6) from refactor/project-structure into main
Reviewed-on: #6
2026-08-02 22:37:30 +00:00
9cf6a7e940 docs: clarify deployed bot version 2026-08-03 01:34:53 +03:00
b8b9ec75b3 refactor: organize repository around legal platform 2026-08-03 01:33:06 +03:00
c94131effc Merge pull request 'feat: export messages after last cutoff' (#5) from feature/export-from-last-cutoff into main
Reviewed-on: agent/telegram-ai-infrastructure#5
2026-08-02 21:51:15 +00:00
d9b4f19af3 feat: export messages after last cutoff 2026-08-03 00:49:35 +03:00
d4ee76948f Merge pull request 'docs: update project status and AI guidance' (#4) from docs/ai-skills-for-beginners into main
Reviewed-on: agent/telegram-ai-infrastructure#4
2026-08-02 21:43:28 +00:00
cf54bc2246 docs: update project status and AI guidance 2026-08-03 00:42:15 +03:00
a67bdbfaa4 Merge pull request 'docs: record Synology deployment' (#3) from feature/synology-deployment into main
Reviewed-on: agent/telegram-ai-infrastructure#3
2026-08-02 21:39:01 +00:00
f4a376ff4b docs: record Synology deployment 2026-08-02 23:55:16 +03:00
ba96b3e633 Merge pull request 'feat: add Telegram secretary bot' (#2) from feature/telegram-secretary-bot into main
Reviewed-on: agent/telegram-ai-infrastructure#2
2026-08-02 20:20:40 +00:00
caaba36a0c feat: add Telegram secretary bot 2026-08-02 23:17:56 +03:00
2921dbc152 Merge pull request 'docs: record project status and Telegram setup' (#1) from codex/project-status-20260731 into main
Reviewed-on: agent/telegram-ai-infrastructure#1
2026-07-31 21:05:50 +00:00
50 changed files with 6168 additions and 156 deletions

16
.dockerignore Normal file
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

8
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Local credentials and secrets
credentials.txt
credentials.json
# Python/runtime artifacts (for the upcoming bot implementation)
__pycache__/
@@ -10,3 +10,9 @@ __pycache__/
.env
.env.*
!.env.example
# Local application archives
data/
# Local runtime logs
logs/

36
CHANGELOG.md Normal file
View File

@@ -0,0 +1,36 @@
# История изменений
## Не выпущено
- Уточнено, что внутренняя лаборатория оценивает выдачу собственного OpenSearch;
ЦБД Минюста служит источником для подтверждения документов, а прежние Excel/PDF
бланки удалены.
- Исключены секреты и локальные данные из Docker build context; синхронизирована версия интерфейса.
- Добавлен production deployment baseline для Search API и OpenSearch с
постоянными хранилищами и healthcheck.
- Добавлено восстановление незавершённой разметки из `localStorage` после перезагрузки страницы.
- Добавлена внутренняя лаборатория проверки поисковой выдачи: просмотр документов,
оценка релевантности 03, комментарии и SQLite-экспорт подписанных снимков.
- Уточнены доступные состояния и адаптивное поведение внутреннего интерфейса
оценки поисковой выдачи.
- Добавлен план внутреннего интерфейса оценки поисковой выдачи юристами.
- Завершён Search API v1: стабильные справочники, валидный OpenAPI, безопасная
пагинация и проверка актуальных редакций в локальном OpenSearch.
- Добавлено безопасное переключение 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 кыргызских запросов с инструкцией по ручной разметке.

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# Акылдаш
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
собирать правовые источники на законных основаниях, сохранять их происхождение
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
через API и пользовательский интерфейс со ссылками на первоисточники.
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2` и backend версии
`0.8.3`: исправлена безопасность production Docker build context.
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.8.3` | исправлена безопасность production build context |
| Frontend | — | ещё не создан |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
## Структура репозитория
```text
docs/ структурированная документация проекта
decisions/ принятые архитектурные и продуктовые решения
operations/ состояние проекта и рабочие процессы
product/ назначение, границы и развитие продукта
team/ материалы для команды
tools/
telegram-bot/ бот рабочего Telegram-пространства
backend/
ingestion/ получение и обновление правовых источников
normalization/ воспроизводимая нормализация исходного архива
```
Каталоги для загрузки и обработки источников, RAG, backend и frontend будут
создаваться с первой реальной задачей в соответствующей области. Это позволит
выбрать структуру по фактическим требованиям, а не поддерживать пустой каркас.
Начать знакомство с проектом: [документация](docs/README.md) и
[обзор продукта](docs/product/project-overview.md).
## Конфиденциальные материалы
Секреты, персональные данные, договоры и материалы по правовой защите проекта
не должны храниться в этом репозитории. Для них нужен отдельный закрытый
репозиторий или защищённое хранилище с минимально необходимыми правами доступа,
журналированием и резервным копированием. Здесь допустимы только несекретные
правила и ссылки на такие материалы без раскрытия их содержания.
## Проверка Telegram-бота
```bash
python3 -m unittest discover -s tools/telegram-bot -v
```
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.8.3 · Frontend — не создан

243
backend/README.md Normal file
View File

@@ -0,0 +1,243 @@
# Backend Акылдаш
Версия: `0.8.3`
Первая 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.8.3 · Frontend — не создан

View 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.8.3"
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())

View 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.8.3"
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())

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

365
backend/search/api.py Normal file
View File

@@ -0,0 +1,365 @@
"""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": "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"}
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", "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}", [])
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}"), "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")}
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())

51
backend/search/catalog.py Normal file
View File

@@ -0,0 +1,51 @@
"""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": ("Прочие органы", "Башка органдар")}
CATALOGS = {"document_type": DOCUMENT_TYPES, "status": STATUSES, "authority": AUTHORITIES}
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"]

View File

@@ -0,0 +1,90 @@
"""Measure document search Recall@K and MRR@K against a relevance set."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from search.minjust_opensearch import APP_VERSION
from search.query import search_documents
def load_queries(path: Path) -> list[dict]:
with path.open(encoding="utf-8") as source:
queries = json.load(source)
if not isinstance(queries, list) or not queries:
raise ValueError("Relevance set must be a non-empty JSON array")
seen = set()
for item in queries:
if not isinstance(item, dict) or set(item) != {
"id", "language", "query", "relevant_document_codes"
}:
raise ValueError("Each query must contain id, language, query and relevant_document_codes")
codes = item["relevant_document_codes"]
if (
not isinstance(item["id"], str)
or not item["id"].strip()
or item["id"] in seen
or not isinstance(item["language"], str)
or item["language"] not in {"ru", "ky"}
or not isinstance(item["query"], str)
or not item["query"].strip()
or not isinstance(codes, list)
or not codes
or any(not isinstance(code, str) or not code for code in codes)
or len(codes) != len(set(codes))
):
raise ValueError(f"Invalid relevance query: {item.get('id', '<unknown>')}")
seen.add(item["id"])
return queries
def search(base_url: str, index: str, item: dict, top_k: int) -> list[str]:
return search_documents(base_url, index, item["language"], item["query"], top_k)
def evaluate(queries: list[dict], base_url: str, index: str, top_k: int) -> dict:
results = []
for item in queries:
retrieved = search(base_url, index, item, top_k)
relevant = set(item["relevant_document_codes"])
matches = [rank for rank, code in enumerate(retrieved, 1) if code in relevant]
results.append({
"id": item["id"],
"language": item["language"],
"query": item["query"],
"retrieved_document_codes": retrieved,
f"recall_at_{top_k}": len(relevant.intersection(retrieved)) / len(relevant),
f"reciprocal_rank_at_{top_k}": 1 / matches[0] if matches else 0.0,
})
return {
"summary": {
"query_count": len(results),
f"recall_at_{top_k}": sum(item[f"recall_at_{top_k}"] for item in results) / len(results),
f"mrr_at_{top_k}": sum(item[f"reciprocal_rank_at_{top_k}"] for item in results) / len(results),
},
"queries": results,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("relevance_set", type=Path)
parser.add_argument("--url", default="http://127.0.0.1:9200")
parser.add_argument("--index", default="akyldash-fragments-v1")
parser.add_argument("--top-k", type=int, default=10)
parser.add_argument("--version", action="version", version=APP_VERSION)
arguments = parser.parse_args()
if arguments.top_k <= 0:
raise SystemExit("--top-k must be greater than zero")
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
print(json.dumps(result, ensure_ascii=False, indent=2))
print(f"Akyldash Backend v{APP_VERSION} · Frontend — not created", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,39 @@
{
"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" },
"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 }
}
}
}

View File

@@ -0,0 +1,467 @@
"""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, source_code
APP_VERSION = "0.8.3"
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")),
"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
View File

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

View File

@@ -0,0 +1,52 @@
[
{"id": "ru-01", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-02", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-03", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-04", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-05", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-06", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-07", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-08", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-09", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-10", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-11", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-12", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-13", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-14", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-15", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-16", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-17", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-18", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-19", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-20", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-21", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-22", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-23", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-24", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ru-25", "language": "ru", "query": "", "relevant_document_codes": []},
{"id": "ky-01", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-02", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-03", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-04", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-05", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-06", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-07", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-08", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-09", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-10", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-11", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-12", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-13", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-14", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-15", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-16", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-17", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-18", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-19", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-20", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-21", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-22", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-23", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-24", "language": "ky", "query": "", "relevant_document_codes": []},
{"id": "ky-25", "language": "ky", "query": "", "relevant_document_codes": []}
]

114
backend/search/review.html Normal file
View 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.8.3 · Внутренняя лаборатория релевантности</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
View 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
View 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()

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

View File

@@ -0,0 +1,361 @@
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[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()

View File

@@ -0,0 +1,68 @@
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))
response = {"aggregations": {name: {"buckets": [{"key": "law" if name == "document_types" else "active" if name == "statuses" else "parliament", "documents": {"value": 3}}]} for name in ("document_types", "statuses", "authorities")}}
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})
body = json.loads(request.call_args.args[2])
self.assertIn("terms", body["aggs"]["document_types"])
self.assertEqual(body["query"], {"term": {"is_current_edition": True}})
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
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", "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", "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", "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"])
filters = api.handle("GET", "/search/filters")[1]
self.assertEqual({item["count"] for item in filters["statuses"]}, {2})
finally:
request_json(f"{base_url}/{index}", "DELETE", None, "application/json")
if __name__ == "__main__":
unittest.main()

View File

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

View File

@@ -0,0 +1,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()

View File

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

View File

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

View File

@@ -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

View 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)"]

View File

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

View File

@@ -0,0 +1,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.

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

View File

@@ -1,118 +0,0 @@
# Статус проекта
Последняя проверка: 2026-07-31
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
## Краткий итог
Telegram-инфраструктура подготовлена для начала разработки бота. Супергруппа и бот существуют, бот добавлен в группу, Privacy Mode отключён, темы созданы и проверены через Telegram Bot API. Исходный код бота пока не создан.
Ближайшая цель — реализовать минимальный архиватор сообщений с сохранением метаданных и Markdown-экспортом обсуждений.
## Уже сделано
### Telegram
- Создана супергруппа `Акылдаш`.
- Создан бот `help_clerk_bot`.
- Бот добавлен в супергруппу с необходимыми правами.
- Privacy Mode бота отключён.
- Созданы и проверены рабочие темы.
- Проверено подключение к Telegram Bot API.
- Webhook у бота не установлен; доступен режим long polling через `getUpdates`.
### ID тем
| Тема | `message_thread_id` | Состояние |
|---|---:|---|
| Общее | отсутствует | стандартная тема |
| MVP | `2` | подтверждено тестовым сообщением |
| Решения | `4` | подтверждено тестовым сообщением |
| Обсуждение | `6` | подтверждено тестовым сообщением |
| Работа с ИИ | `8` | подтверждено тестовым сообщением |
### Документация и правила
- Подготовлен общий план реализации в `TEAM_AI_TELEGRAM_IMPLEMENTATION_PLAN.md`.
- Зафиксированы границы MVP и правила работы с данными в `docs/decisions/001-mvp-boundaries-and-rules.md`.
- В MVP не входит автоматический вызов API языковой модели.
- Анализ экспортов выполняется вручную в обычном ChatGPT.
- Telegram используется как рабочий штаб, Git/Gitea — как источник утверждённых материалов.
### Доступы
- Локальные доступы находятся в `credentials.txt`.
- `credentials.txt` добавлен в `.gitignore`.
- Секреты нельзя добавлять в Git, логи, экспорты или сообщения бота.
## Пока не сделано
### Репозиторий и приложение
- Не создан исходный код бота.
- Не определена и не зафиксирована структура Python-проекта.
- Локальный каталог `.git` пуст; локальный Git-репозиторий ещё нужно корректно инициализировать или привязать к удалённому репозиторию Gitea.
- Не настроены Docker-файлы для Synology.
- Не настроен CI/CD.
### Хранилище
- Не подключена база данных.
- Не утверждена окончательная схема PostgreSQL.
- Не определены сроки хранения сообщений, вложений и экспортов.
- Не определён согласованный список пользователей с доступом к архиву.
### Функции бота
- Приём и сохранение сообщений.
- Сохранение автора, даты, темы, ID сообщения и reply-связи.
- Сохранение доступных вложений.
- Ручная пометка сообщений для экспорта.
- Экспорт темы или диапазона в Markdown.
- Формирование готовой инструкции для ручной загрузки в ChatGPT.
- Возврат отчёта пользователя в Telegram.
- Команды `/help`, `/status` и команда экспорта.
### Группа и рабочий процесс
- Не подготовлены закреплённые сообщения с правилами тем.
- Не проверены сценарии закрытия темы `MVP` после фиксации состава первой версии.
- Не настроены уведомления Gitea.
- Не проведён полный пользовательский тест: сообщение → сохранение → экспорт → анализ в ChatGPT → возврат отчёта.
## Важные неизвестные
Перед реализацией постоянного развёртывания нужно получить или принять решения по следующим вопросам:
1. Адрес и параметры PostgreSQL для разработки и Synology.
2. Имя и URL удалённого репозитория Gitea.
3. Список пользователей Telegram, которым разрешён экспорт и работа с архивом.
4. Правила хранения и удаления сообщений и вложений.
5. Способ запуска бота: long polling на старте или webhook после развёртывания.
6. Нужен ли SQLite для локальной разработки как временное хранилище до подключения PostgreSQL.
## Следующий этап
### Stage 2 — бот-архиватор
Рекомендуемый порядок:
1. Создать каркас Python-приложения и конфигурацию через переменные окружения.
2. Добавить фильтр группы `-1004242041275` и разрешённых тем `2`, `4`, `6`, `8`.
3. Реализовать приём сообщений и сохранение метаданных.
4. Добавить минимальную схему хранилища.
5. Реализовать ручную отметку сообщений.
6. Реализовать Markdown-экспорт с контекстом, авторами, датами, reply-связями и вложениями.
7. Добавить команды справки и состояния.
8. Проверить работу на реальном обсуждении и зафиксировать результат в этом документе.
## История изменений статуса
### 2026-07-31
- Создана супергруппа и добавлен бот.
- Отключён Privacy Mode.
- Определены ID тем: `2`, `4`, `6`, `8`.
- Подтверждено отсутствие webhook.
- Зафиксировано, что код бота и хранилище ещё не реализованы.

42
docs/README.md Normal file
View 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.8.3 · Frontend — не создан

View File

@@ -11,7 +11,7 @@ Telegram не является хранилищем проекта, а бот и
## Что входит в MVP
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`.
- Telegram-супергруппа с темами `Общее` (стандартная тема), `MVP`, `Решения`, `Обсуждение`, `Работа с ИИ` и `Отчеты`.
- Тема `MVP` используется только для обсуждения границ первой версии. После фиксации MVP тема закрывается.
- Тема `Обсуждение` используется для рабочих гипотез и вопросов, которые ещё не стали подтверждёнными решениями.
- В теме `Решения` бот публикует итоговые сводки и подтверждённые решения после завершения обсуждения. Сообщения бота являются единственным редактируемым источником итогового текста; пользователи не редактируют сообщения бота.
@@ -20,6 +20,7 @@ Telegram не является хранилищем проекта, а бот и
- Для сообщения сохраняются текст, автор, дата и время, тема, идентификатор сообщения и связь с ответом.
- Экспорт выполняется по ручной отметке участника или по явно заданному диапазону темы.
- Экспорт формируется в Markdown и включает контекст, сообщения по времени, ссылки и доступные вложения.
- Markdown публикуется в закрытой для сообщений участников теме `Отчеты`. В исходной теме бот оставляет заметную отсечку, прямую ссылку и общий с отчётом уникальный хэштег.
- Участник вручную загружает экспорт в ChatGPT и возвращает результат в Telegram.
- Gitea-уведомления на старте можно публиковать в `Общее` либо в отдельную тему после появления такой потребности. События ограничиваются PR, задачами, дедлайнами и сбоями важных проверок.
- Подтверждённые решения и изменения документации фиксируются в Git/Gitea обычным review-процессом.
@@ -46,15 +47,16 @@ Telegram не является хранилищем проекта, а бот и
- Используются темы `MVP`, `Решения`, `Обсуждение` и `Работа с ИИ`; тема `Общее` остаётся стандартной.
- `MVP` закрывается после фиксации состава первой версии.
- Итог обсуждения формирует бот и публикует в `Решения`.
- Файлы экспортов публикуются в теме `Отчеты`; исходные темы не засоряются файлами.
- `Работа с ИИ` предназначена только для публикаций бота и будет содержать best practice, а не обычный FAQ.
- Остальные параметры первой версии из этого документа считаются утверждёнными, если команда не изменит их отдельным решением.
## Граница между ботом и ИИ
- Бот без обращения к API языковой модели полностью выполняет сбор сообщений, сортировку, добавление метаданных, подготовку вложений и формирование Markdown-экспорта.
- Бот добавляет к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
- На следующем этапе бот должен добавлять к экспорту готовую инструкцию для ручной загрузки в обычный ChatGPT.
- Выделение решений, задач, рисков, противоречий и открытых вопросов выполняется в ChatGPT вручную через подписку участника.
- Бот может принять возвращённый человеком отчёт, опубликовать его в Telegram и связать с исходным обсуждением.
- На следующем этапе бот должен принимать возвращённый человеком отчёт, публиковать его в Telegram и связывать с исходным обсуждением.
- Бот не выдаёт эвристический или шаблонный результат за выполненный ИИ-анализ. Поиск ключевых слов и ручные метки допустимы только как вспомогательная навигация.
## Техническое замечание о правах тем
@@ -63,13 +65,17 @@ Telegram позволяет запретить пользователям отп
## Инфраструктурные предпосылки
- Доступы для настройки находятся в `credentials.txt`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
- Доступы для настройки находятся в локальном `credentials.json`; секреты из этого файла нельзя включать в Git, логи CI/CD, экспорт Telegram или сообщения бота.
- Для проекта можно создать открытый репозиторий в Gitea.
- В Gitea необходимо настроить защиту `main`: прямые push запрещены, изменения проходят через pull request и проверки CI/CD.
- Развёртывание бота планируется на Docker-инфраструктуре Synology.
- Бот развёрнут в Container Manager на Synology с постоянным SQLite-томом и политикой автоперезапуска; конфигурацию развёртывания ещё предстоит зафиксировать в репозитории.
- PostgreSQL будет использоваться как хранилище бота; параметры доступа к базе будут предоставлены отдельно.
- Почта может использоваться как дополнительный канал доставки файлов и отчётов, но не как основное хранилище.
## Критерий завершения Stage 0
Границы MVP, состав тем и правила работы с данными подтверждены. Остаётся реализовать жизненный цикл тем и экспортов, после чего можно проектировать схему хранения и команды бота, не меняя назначение первой версии по ходу реализации.
Критерий выполнен: границы MVP зафиксированы, темы созданы, первая версия бота и экспортов реализована. Полный рабочий цикл будет принят командой после проверки на реальном обсуждении.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -0,0 +1,265 @@
# Статус проекта
Последняя проверка: 2026-09-12
Назначение документа: быстро восстановить контекст проекта для участников команды и будущих агентов.
- Telegram-бот: `0.2.2`
- Telegram-бот на Synology: `0.2.1`
- Backend: `0.8.3`
- 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.8.3 · Frontend — не создан

View File

@@ -276,57 +276,59 @@ docs/
## 9. Этапы реализации
Обозначения: `[x]` — выполнено, `[ ]` — ещё не выполнено.
### Этап 0. Границы и правила
- утвердить назначение Telegram, Git, бота и ChatGPT;
- выбрать темы, которые бот слушает;
- определить, что можно собирать автоматически;
- определить правила обработки персональных и конфиденциальных данных;
- определить формат решений, задач и экспортов.
- [x] утвердить назначение Telegram, Git, бота и ChatGPT;
- [x] выбрать темы, которые бот слушает;
- [x] определить, что можно собирать автоматически;
- [x] определить правила обработки персональных и конфиденциальных данных;
- [x] определить формат решений, задач и экспортов.
### Этап 1. Рабочая Telegram-группа
- создать супергруппу и темы;
- подготовить закреплённые сообщения;
- создать первоначальный FAQ;
- согласовать правила обсуждений и фиксации решений.
- [x] создать супергруппу и темы;
- [x] подготовить и закрепить приветствие с навигацией;
- [x] создать первоначальную библиотеку практик работы с ИИ;
- [x] согласовать правила обсуждений и фиксации решений.
### Этап 2. Бот-архиватор
- реализовать приём сообщений;
- сохранить метаданные и вложения;
- добавить ручную отметку для экспорта;
- сформировать Markdown-экспорт;
- проверить работу с темами и ответами.
- [x] реализовать приём сообщений;
- [x] сохранять сообщения и метаданные вложений;
- [ ] добавить ручную отметку отдельных сообщений для экспорта;
- [x] сформировать Markdown-экспорт;
- [x] проверить работу с темами и ответами.
### Этап 3. Уведомления
- подключить события Gitea;
- настроить фильтрацию;
- направлять уведомления в отдельную тему;
- добавить напоминания о задачах и сроках.
- [ ] подключить события Gitea;
- [ ] настроить фильтрацию;
- [ ] направлять уведомления в отдельную тему;
- [ ] добавить напоминания о задачах и сроках.
### Этап 4. ИИ-подготовка без API
- добавить шаблоны запросов для анализа;
- создавать готовые файлы для загрузки в ChatGPT;
- добавить возврат отчёта в Telegram;
- проверить несколько реальных обсуждений.
- [ ] добавить шаблоны запросов для анализа;
- [x] создавать готовые файлы для загрузки в ChatGPT;
- [ ] добавить возврат отчёта в Telegram;
- [ ] проверить несколько реальных обсуждений.
### Этап 5. Связка с Git/Gitea
- подготовить структуру документации;
- настроить ссылки на задачи, PR и документы;
- определить процесс переноса подтверждённых решений в Git;
- добавить сценарии обновления документации через ИИ.
- [x] подготовить структуру документации;
- [ ] настроить ссылки на задачи, PR и документы;
- [x] определить процесс переноса подтверждённых решений в Git;
- [ ] добавить сценарии обновления документации через ИИ.
### Этап 6. Улучшение по фактическому использованию
- удалить невостребованные функции;
- расширить FAQ реальными сценариями команды;
- улучшить формат отчётов;
- добавить новые уведомления только при наличии потребности;
- оценить необходимость API и автоматического ИИ-анализа.
- [ ] удалить невостребованные функции;
- [ ] расширить библиотеку практик реальными сценариями команды;
- [ ] улучшить формат отчётов;
- [ ] добавить новые уведомления только при наличии потребности;
- [ ] оценить необходимость API и автоматического ИИ-анализа.
---
@@ -393,3 +395,7 @@ Git сохраняет актуальную версию
```
Цель инфраструктуры — не заставить специалистов изучать больше технологий, а сделать технологии удобным продолжением их профессиональной работы.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

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

View 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. Подготовить тестовый набор из 50100 реальных запросов на русском и
кыргызском языках и вручную отметить ожидаемые результаты.
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-сборка и развёртывание;
- пользовательское тестирование на 1015 реальных юридических задачах.
Результат: публичный MVP.
Оценка frontend-части после готовности API: **9 недель**.
## Спринты после MVP
### Спринт 5 — персональный кабинет
Авторизация, закладки, заметки, подборки и сохранённые фильтры.
### Спринт 6 — контроль изменений
Документы на контроле, подписки на редакции и уведомления.
### Спринт 7 — юридические связи
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
### Спринт 8 — практические материалы
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
### Спринт 9 — расширенный анализ
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
ссылками на источники.
### Спринт 10 — корпоративные функции
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
персонализация.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View 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 — не создан

View 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 — не создан

View File

@@ -0,0 +1,78 @@
# Справочники Search API v1
Статус: утверждённый контракт для Search 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` | Основные принципы | Основные принципы |
## Статусы
| Code | RU | KY |
| --- | --- | --- |
| `active` | Действует | Күчүндө |
| `repealed` | Утратил силу | Күчүн жоготту |
| `unspecified` | Не указан | Көрсөтүлгөн эмес |
## Органы принятия
Орган содержит два уровня: стабильную группу и конкретный орган. В 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`),
когда источник предоставит такие идентификаторы либо будет утверждён вручную
поддерживаемый реестр.
## Правила совместимости
- Код из этой таблицы нельзя переиспользовать и нельзя менять его значение.
- Новое значение добавляется только новой записью; удалённое остаётся доступно
для старых документов.
- Запрос с неизвестным кодом возвращает `400`.

View File

@@ -0,0 +1,179 @@
# План внутреннего интерфейса оценки поисковой выдачи
## Цель
Создать закрытую лабораторию релевантности: юрист оценивает фактическую выдачу
нашего OpenSearch по практическим запросам. Цель — улучшать собственное
ранжирование, а не воспроизводить алгоритм сайта Минюста КР.
ЦБД Минюста используется как официальный источник текста, реквизитов, статуса
и редакции акта. Порядок результатов и оценка их полезности определяются в
нашем сервисе.
Это внутренний рабочий инструмент, а не публичный frontend MVP. Он не включает
регистрацию, личные кабинеты, публичный дизайн, сложные фильтры или сравнение
редакций.
## Сценарий юриста
1. Указать поисковый запрос и язык.
2. Получить первые 1020 результатов в точном порядке 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. Утвердить шкалу 03, обязательность имени проверяющего и правила доступа.
2. Добавить SQLite-хранилище, валидацию, сохранение и JSON-экспорт.
3. Добавить статическую внутреннюю страницу в существующий Python-сервер, без
Next.js и отдельного публичного приложения.
4. Подключить поиск, правую панель документа, черновик, адаптивный режим и
сохранение в закреплённой панели действий на широком экране.
5. Добавить минимальные backend-проверки сохранения, повторного запуска,
экспорта и недопустимых оценок.
6. Провести ручный прогон на десяти русскоязычных практических запросах с
двумя юристами.
7. На собранных записях настроить OpenSearch и повторить тот же набор запросов.
## Критерий готовности
Юрист вводит запрос, видит порядок выдачи, открывает документ, выставляет
оценки и комментарии, сохраняет их. Экспорт содержит запрос, язык, документ
на позиции `#1`, полный порядок результатов, оценки, комментарии и версию
индекса. Данные можно сравнить до и после изменения алгоритма.
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -0,0 +1,183 @@
# Важные навыки работы с ИИ для начинающих
Каждый раздел ниже — отдельное готовое сообщение для темы `Работа с ИИ`.
Текст можно публиковать без заголовков этого файла: название, хэштеги и пояснение уже входят в сообщение.
## 1. Ставьте задачу через результат
#работа_с_ии #промпт #цель
Пишите не тему, а желаемый результат. Вместо «расскажи о договоре» — «составь краткий список рисков договора для руководителя». Чем яснее результат, тем меньше случайных ответов.
## 2. Давайте нужный контекст
#работа_с_ии #контекст #источники
Укажите, для кого готовится результат, что уже известно и какие файлы или сообщения использовать. Не заставляйте ИИ угадывать факты, которые можно дать сразу.
## 3. Называйте ограничения
#работа_с_ии #ограничения #точность
Сразу укажите важные рамки: объём, срок, язык, допустимые источники и что нельзя менять. Пример: «до одной страницы, только по приложенным документам, без вымышленных данных».
## 4. Задавайте формат ответа
#работа_с_ии #формат #результат
Скажите, в каком виде нужен ответ: список, таблица, письмо, план или Markdown. Укажите желаемую длину и аудиторию. Это полезнее просьбы «ответь красиво».
## 5. Определяйте, что значит «готово»
#работа_с_ии #критерии #готовность
Добавьте проверяемый критерий завершения: «все риски связаны с пунктами договора», «у каждой задачи есть ответственный и срок», «тесты проходят». ИИ должен понимать финиш задачи.
## 6. Показывайте пример хорошего результата
#работа_с_ии #пример #качество
Если важны стиль или структура, приложите короткий образец. Один хороший пример обычно работает лучше длинного описания вроде «сделай профессионально».
## 7. Просите уточнить неясное
#работа_с_ии #уточнение #вопросы
Если задача допускает разные трактовки, напишите: «Сначала задай до трёх важных вопросов, затем приступай». Это снижает риск получить уверенный ответ не на тот вопрос.
## 8. Отделяйте факты от предположений
#работа_с_ии #факты #проверка
Просите отдельно отмечать подтверждённые факты, выводы и неизвестные данные. Для актуальной информации требуйте ссылки и дату проверки. Уверенный тон не является доказательством.
## 9. Для сложной задачи сначала просите план
#работа_с_ии #план #сложная_задача
Сначала попросите изучить материалы и предложить короткий план. После вашего подтверждения ИИ выполняет работу. Так ошибки направления обнаруживаются до больших изменений.
## 10. Делите большую работу на этапы
#работа_с_ии #этапы #контроль
Разбивайте задачу на небольшие проверяемые результаты: анализ → проект → проверка → финал. Подтверждайте важные этапы отдельно, особенно перед публикацией или изменением данных.
## 11. Используйте персональные инструкции
#работа_с_ии #персональные_инструкции #настройка
Персональные инструкции — ваши постоянные предпочтения для всех чатов: язык, стиль, желаемая краткость и способ объяснения. Не храните там пароли и правила одного конкретного проекта.
## 12. Настраивайте инструкции проекта
#работа_с_ии #инструкции_проекта #проект
Для каждого проекта отдельно зафиксируйте цель, термины, источники истины, правила работы и критерии готовности. В ChatGPT используйте инструкции проекта, в Codex — репозиторный `AGENTS.md`.
## 13. Делайте инструкции короткими и проверяемыми
#работа_с_ии #инструкции #порядок
Пишите конкретно: «перед изменением создай ветку», а не «работай правильно». Удаляйте устаревшие и противоречивые правила. Добавляйте новое правило после повторяющейся ошибки, а не заранее.
## 14. Один чат — один понятный результат
#работа_с_ии #чат #контекст
В одном чате держите одну связанную цель. Для отдельной задачи начинайте новый чат; для настоящего ответвления создавайте копию или fork. Перегруженный контекст ухудшает качество.
## 15. Храните решения вне переписки
#работа_с_ии #решения #источник_истины
Чат помогает думать, но не должен быть единственным хранилищем решения. Подтверждённый результат переносите в документ, задачу или Git, где видны актуальная версия и история изменений.
## 16. Понимайте назначение агентов
#работа_с_ии #агенты #делегирование
Агенты — отдельные исполнители для ограниченных частей большой задачи. Они полезны, когда несколько независимых исследований или проверок можно выполнить параллельно.
## 17. Давайте агенту одну ограниченную задачу
#работа_с_ии #агенты #постановкаадачи
Каждому агенту задайте одну цель, входные материалы, границы и формат результата. Хорошо: «проверь только риски безопасности и верни пять находок со ссылками».
## 18. Не используйте агентов без необходимости
#работа_с_ии #агенты #простота
Для маленькой или строго последовательной задачи один исполнитель лучше. Несколько агентов тратят больше ресурсов, могут дублировать работу и конфликтовать при одновременном изменении одних файлов.
## 19. Оставляйте итог главному исполнителю
#работа_с_ии #агенты #итог
Агенты возвращают краткие выводы, а главный исполнитель сравнивает их, устраняет противоречия и готовит единый ответ. Не склеивайте сырые результаты без общей проверки.
## 20. Заранее задавайте правило остановки
#работа_с_ии #зацикливание #стоп
Для сложной задачи напишите: «После двух неудачных попыток остановись, перечисли проверенное, назови блокер и предложи другой подход». Это не даёт ИИ бесконечно повторять одно решение.
## 21. После неудачи меняйте метод, а не формулировку
#работа_с_ии #ошибка #диагностика
Если подход не сработал, попросите назвать причину и собрать новые доказательства. Повтор той же команды другими словами редко помогает. Нужна новая гипотеза или дополнительный источник данных.
## 22. Очищайте перегруженный контекст
#работа_с_ии #контекст #перезапуск
Если ИИ путает старые решения, попросите кратко зафиксировать цель, факты, принятые решения и открытый вопрос. Затем продолжите из этой сводки в новом чате.
## 23. Всегда проверяйте результат
#работа_с_ии #проверка #качество
Просите ИИ выполнить самопроверку, но важные утверждения проверяйте сами по первичным источникам. Для кода нужны тесты, для расчётов — пересчёт, для документов — сверка цитат и реквизитов.
## 24. Не передавайте лишние данные
#работа_с_ии #приватность #персональныеанные
Перед загрузкой удалите лишние ФИО, контакты, пароли, ключи и конфиденциальные сведения. Передавайте минимальный объём данных, необходимый для задачи, и учитывайте правила вашей организации.
## 25. Отделяйте анализ от действия
#работа_с_ии #безопасность #подтверждение
Просьба «проанализируй» не должна означать «отправь, удали или опубликуй». Для внешних и необратимых действий требуйте отдельный план, предварительный просмотр и явное подтверждение человека.
## 26. Подключайте только нужные инструменты
#работа_с_ии #инструменты #доступ
Давайте ИИ доступ только к тем файлам, сервисам и правам, которые нужны сейчас. Начните с чтения; разрешение на запись, отправку и удаление выдавайте отдельно.
## 27. Превращайте повторяемую работу в шаблон
#работа_с_ии #шаблон #автоматизация
Если удачный запрос используется регулярно, сохраните его как шаблон. Когда процесс стал стабильным и проверенным, оформите его как навык или автоматизацию. Не автоматизируйте ещё не понятный процесс.
## 28. Завершайте работу короткой приёмкой
#работа_с_ии #приемка #финальная_проверка
В конце спросите: «Что сделано, что проверено, что осталось и какие есть риски?» Сравните ответ с первоначальной целью и критериями готовности, прежде чем принимать результат.
## Справочные материалы
- [Prompting](https://learn.chatgpt.com/docs/prompting)
- [Personalize ChatGPT](https://learn.chatgpt.com/docs/personalize)
- [Subagents](https://learn.chatgpt.com/docs/agent-configuration/subagents)
- [Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md)
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.7.1 · Frontend — не создан

View File

@@ -0,0 +1,8 @@
TELEGRAM_BOT_TOKEN=replace-me
TELEGRAM_CHAT_ID=-1004242041275
TELEGRAM_OWNER_ID=87262245
TELEGRAM_REPORT_THREAD_ID=37
TELEGRAM_ALLOWED_THREAD_IDS=0,2,4,6,8
BOT_DATABASE=data/secretary.sqlite3
APP_TIMEZONE=Asia/Bishkek
LOG_LEVEL=INFO

View 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 — не создан

458
tools/telegram-bot/bot.py Executable file
View File

@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""Minimal Telegram secretary: archive allowed topics and export them to Markdown."""
from __future__ import annotations
import json
import logging
import os
import sqlite3
import time
import urllib.error
import urllib.request
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
APP_NAME = "Акылдаш"
APP_VERSION = "0.2.2"
FOOTER = f"{APP_NAME} v{APP_VERSION}"
@dataclass(frozen=True)
class Config:
token: str
chat_id: int
owner_id: int
report_thread_id: int
thread_ids: frozenset[int]
database: Path
timezone: ZoneInfo
def load_config() -> Config:
token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
chat_id = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
owner_id = os.environ.get("TELEGRAM_OWNER_ID", "").strip()
report_thread_id = os.environ.get("TELEGRAM_REPORT_THREAD_ID", "").strip()
if not token or not chat_id or not owner_id or not report_thread_id:
raise SystemExit(
"Set TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, TELEGRAM_OWNER_ID "
"and TELEGRAM_REPORT_THREAD_ID"
)
try:
threads = frozenset(
int(value.strip())
for value in os.environ.get(
"TELEGRAM_ALLOWED_THREAD_IDS", "0,2,4,6,8"
).split(",")
if value.strip()
)
return Config(
token=token,
chat_id=int(chat_id),
owner_id=int(owner_id),
report_thread_id=int(report_thread_id),
thread_ids=threads,
database=Path(os.environ.get("BOT_DATABASE", "data/secretary.sqlite3")),
timezone=ZoneInfo(os.environ.get("APP_TIMEZONE", "Asia/Bishkek")),
)
except (ValueError, ZoneInfoNotFoundError) as error:
raise SystemExit(f"Invalid configuration: {error}") from error
def connect(database: Path) -> sqlite3.Connection:
database.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(database)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS messages (
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
thread_id INTEGER NOT NULL,
sent_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
update_id INTEGER NOT NULL,
payload TEXT NOT NULL,
PRIMARY KEY (chat_id, message_id)
);
CREATE INDEX IF NOT EXISTS messages_thread_date
ON messages (chat_id, thread_id, sent_at);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
)
return connection
def archive_update(
connection: sqlite3.Connection, update: dict, config: Config
) -> dict | None:
update_id = int(update["update_id"])
message = update.get("message") or update.get("edited_message")
with connection:
if message:
chat_id = int(message["chat"]["id"])
thread_id = int(message.get("message_thread_id", 0))
if chat_id == config.chat_id and thread_id in config.thread_ids:
connection.execute(
"""
INSERT INTO messages (
chat_id, message_id, thread_id, sent_at, updated_at,
update_id, payload
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (chat_id, message_id) DO UPDATE SET
thread_id = excluded.thread_id,
updated_at = excluded.updated_at,
update_id = excluded.update_id,
payload = excluded.payload
""",
(
chat_id,
int(message["message_id"]),
thread_id,
int(message["date"]),
int(message.get("edit_date", message["date"])),
update_id,
json.dumps(message, ensure_ascii=False),
),
)
else:
message = None
connection.execute(
"""
INSERT INTO state (key, value) VALUES ('next_update_id', ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
""",
(str(update_id + 1),),
)
return message
def next_update_id(connection: sqlite3.Connection) -> int:
row = connection.execute(
"SELECT value FROM state WHERE key = 'next_update_id'"
).fetchone()
return int(row["value"]) if row else 0
def export_checkpoint(
connection: sqlite3.Connection, chat_id: int, thread_id: int
) -> int:
row = connection.execute(
"SELECT value FROM state WHERE key = ?",
(f"export_checkpoint:{chat_id}:{thread_id}",),
).fetchone()
return int(row["value"]) if row else 0
def save_export_checkpoint(
connection: sqlite3.Connection, chat_id: int, thread_id: int, message_id: int
) -> None:
with connection:
connection.execute(
"""
INSERT INTO state (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = excluded.value
""",
(f"export_checkpoint:{chat_id}:{thread_id}", str(message_id)),
)
def telegram_request(
config: Config, method: str, payload: dict, timeout: int = 65
) -> object:
request = urllib.request.Request(
f"https://api.telegram.org/bot{config.token}/{method}",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(result.get("description", "Telegram API error"))
return result["result"]
def send_text(config: Config, message: dict, text: str) -> None:
payload = {
"chat_id": config.chat_id,
"text": f"{text}\n\n{FOOTER}",
"reply_parameters": {"message_id": message["message_id"]},
}
if message.get("message_thread_id"):
payload["message_thread_id"] = message["message_thread_id"]
telegram_request(config, "sendMessage", payload)
def send_document(
config: Config, thread_id: int, filename: str, content: bytes, caption: str
) -> dict:
boundary = uuid.uuid4().hex
fields = {
"chat_id": str(config.chat_id),
"message_thread_id": str(thread_id),
"caption": caption,
}
body = bytearray()
for name, value in fields.items():
body.extend(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
f"\r\n\r\n{value}\r\n".encode()
)
body.extend(
f"--{boundary}\r\nContent-Disposition: form-data; name=\"document\"; "
f"filename=\"{filename}\"\r\nContent-Type: text/markdown; charset=utf-8"
f"\r\n\r\n".encode()
)
body.extend(content)
body.extend(f"\r\n--{boundary}--\r\n".encode())
request = urllib.request.Request(
f"https://api.telegram.org/bot{config.token}/sendDocument",
data=bytes(body),
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
with urllib.request.urlopen(request, timeout=65) as response:
result = json.load(response)
if not result.get("ok"):
raise RuntimeError(result.get("description", "Telegram API error"))
return result["result"]
def author(message: dict) -> str:
sender = message.get("from", {})
name = " ".join(
part for part in (sender.get("first_name"), sender.get("last_name")) if part
)
username = sender.get("username")
if name and username:
return f"{name} (@{username})"
return name or (f"@{username}" if username else "Неизвестный участник")
def attachment_descriptions(message: dict) -> list[str]:
descriptions: list[str] = []
if message.get("photo"):
descriptions.append("фотография")
for field, label in (
("document", "документ"),
("video", "видео"),
("audio", "аудио"),
("voice", "голосовое сообщение"),
("animation", "анимация"),
("sticker", "стикер"),
):
attachment = message.get(field)
if attachment:
name = attachment.get("file_name") or attachment.get("emoji")
descriptions.append(f"{label}: {name}" if name else label)
return descriptions
def message_link(chat_id: int, message_id: int) -> str:
return f"https://t.me/c/{str(chat_id).removeprefix('-100')}/{message_id}"
def export_markdown(
connection: sqlite3.Connection,
config: Config,
thread_id: int,
days: int | None,
now: int | None = None,
after_message_id: int | None = None,
) -> str:
parameters: list[int] = [config.chat_id, thread_id]
condition = ""
if after_message_id is not None:
condition = " AND message_id > ?"
parameters.append(after_message_id)
elif days is not None:
condition = " AND sent_at >= ?"
parameters.append((now or int(time.time())) - days * 86400)
rows = connection.execute(
f"""
SELECT message_id, sent_at, payload FROM messages
WHERE chat_id = ? AND thread_id = ?{condition}
ORDER BY sent_at, message_id
""",
parameters,
).fetchall()
if after_message_id is not None:
period = (
"с начала архива"
if after_message_id == 0
else "после предыдущей отсечки"
)
else:
period = "за всё время" if days is None else f"за последние {days} дн."
lines = [
"# Обсуждение",
"",
f"Тема: `{thread_id or 'Общее'}` ",
f"Период: {period} ",
f"Экспортировано: {datetime.now(config.timezone):%Y-%m-%d %H:%M %Z}",
"",
"## Сообщения",
]
for row in rows:
message = json.loads(row["payload"])
text = message.get("text") or message.get("caption") or "[Служебное событие Telegram]"
if text.split(maxsplit=1)[0].split("@", 1)[0] in {"/help", "/status", "/export"}:
continue
sent_at = datetime.fromtimestamp(row["sent_at"], timezone.utc).astimezone(
config.timezone
)
lines.extend(
[
"",
f"### {author(message)}{sent_at:%Y-%m-%d %H:%M}",
"",
f"[Открыть сообщение]({message_link(config.chat_id, row['message_id'])})",
]
)
reply = message.get("reply_to_message", {}).get("message_id")
if reply and reply != message.get("message_thread_id"):
lines.append(f"Ответ на сообщение: #{reply}")
lines.extend(["", text])
attachments = attachment_descriptions(message)
if attachments:
lines.extend(["", "Вложения:", *[f"- {item}" for item in attachments]])
lines.extend(["", "---", FOOTER, ""])
return "\n".join(lines)
def parse_export_days(parts: list[str]) -> int | None:
if len(parts) == 1:
return 7
if parts[1].lower() in {"all", "все"}:
return None
days = int(parts[1])
if not 1 <= days <= 3650:
raise ValueError
return days
def handle_command(
connection: sqlite3.Connection, config: Config, message: dict
) -> None:
text = message.get("text", "")
if not text.startswith("/"):
return
parts = text.split()
command = parts[0].split("@", 1)[0].lower()
thread_id = int(message.get("message_thread_id", 0))
if command == "/help":
send_text(
config,
message,
"Я сохраняю обсуждения этой группы.\n"
"/status — количество сохранённых сообщений\n"
"/export — экспорт текущей темы после предыдущей отсечки",
)
elif command == "/status":
topic_count = connection.execute(
"SELECT COUNT(*) FROM messages WHERE chat_id = ? AND thread_id = ?",
(config.chat_id, thread_id),
).fetchone()[0]
total_count = connection.execute(
"SELECT COUNT(*) FROM messages WHERE chat_id = ?", (config.chat_id,)
).fetchone()[0]
send_text(
config,
message,
f"Сохранено сообщений: {topic_count} в этой теме, {total_count} всего.",
)
elif command == "/export":
if int(message.get("from", {}).get("id", 0)) != config.owner_id:
send_text(config, message, "Экспорт доступен только владельцу бота.")
return
try:
days = parse_export_days(parts) if len(parts) > 1 else None
except (ValueError, IndexError):
send_text(config, message, "Использование: /export [13650|все]")
return
checkpoint = (
None
if len(parts) > 1
else export_checkpoint(connection, config.chat_id, thread_id)
)
markdown = export_markdown(
connection,
config,
thread_id,
days,
after_message_id=checkpoint,
)
stamp = datetime.now(config.timezone).strftime("%Y%m%d-%H%M")
report_tag = f"#report_{message['message_id']}"
report = send_document(
config,
config.report_thread_id,
f"discussion-{thread_id}-{stamp}.md",
markdown.encode(),
f"{report_tag}\nИсходное обсуждение: "
f"{message_link(config.chat_id, message['message_id'])}\n\n{FOOTER}",
)
send_text(
config,
message,
"━━━━━━━━━━━━━━━━\n"
"✅ ОБСУЖДЕНИЕ ЗАВЕРШЕНО\n"
f"{report_tag}\n"
f"Отчёт: {message_link(config.chat_id, report['message_id'])}\n"
"━━━━━━━━━━━━━━━━",
)
save_export_checkpoint(
connection, config.chat_id, thread_id, int(message["message_id"])
)
def run() -> None:
config = load_config()
connection = connect(config.database)
logging.info("Starting %s with database %s", FOOTER, config.database)
while True:
try:
updates = telegram_request(
config,
"getUpdates",
{
"offset": next_update_id(connection),
"timeout": 50,
"allowed_updates": ["message", "edited_message"],
},
)
for update in updates:
message = archive_update(connection, update, config)
if message:
handle_command(connection, config, message)
except (urllib.error.URLError, TimeoutError, RuntimeError, OSError, ValueError):
logging.exception("Polling failed; retrying in 5 seconds")
time.sleep(5)
if __name__ == "__main__":
logging.basicConfig(
level=os.environ.get("LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(message)s",
)
try:
run()
except KeyboardInterrupt:
logging.info("Stopped")

View File

@@ -0,0 +1,238 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from zoneinfo import ZoneInfo
from bot import (
APP_VERSION,
Config,
archive_update,
connect,
export_markdown,
handle_command,
next_update_id,
)
class SecretaryTest(unittest.TestCase):
def test_archives_allowed_topic_and_exports_reply(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-1004242041275,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({2}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("Asia/Bishkek"),
)
connection = connect(config.database)
message = {
"message_id": 12,
"message_thread_id": 2,
"date": 1_700_000_000,
"chat": {"id": config.chat_id},
"from": {"id": 7, "first_name": "Айжан"},
"text": "Зафиксируем это решение.",
"reply_to_message": {"message_id": 11},
}
archived = archive_update(
connection, {"update_id": 40, "message": message}, config
)
markdown = export_markdown(
connection, config, thread_id=2, days=None, now=1_700_000_001
)
self.assertEqual(archived, message)
self.assertEqual(next_update_id(connection), 41)
self.assertIn("Айжан", markdown)
self.assertIn("Зафиксируем это решение.", markdown)
self.assertIn("Ответ на сообщение: #11", markdown)
self.assertIn(f"Акылдаш v{APP_VERSION}", markdown)
def test_ignores_other_chat_but_advances_offset(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-1,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({0}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
update = {
"update_id": 5,
"message": {
"message_id": 1,
"date": 1,
"chat": {"id": -2},
"text": "Не наша группа",
},
}
self.assertIsNone(archive_update(connection, update, config))
self.assertEqual(next_update_id(connection), 6)
self.assertEqual(connection.execute("SELECT COUNT(*) FROM messages").fetchone()[0], 0)
def test_omits_forum_topic_root_reply_from_export(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-1,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({2}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
message = {
"message_id": 12,
"message_thread_id": 2,
"date": 1,
"chat": {"id": config.chat_id},
"from": {"id": config.owner_id},
"text": "Сообщение темы",
"reply_to_message": {"message_id": 2},
}
archive_update(connection, {"update_id": 1, "message": message}, config)
markdown = export_markdown(connection, config, 2, None)
self.assertNotIn("Ответ на сообщение: #2", markdown)
def test_rejects_export_from_non_owner(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-1,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({0}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
message = {
"message_id": 1,
"date": 1,
"chat": {"id": config.chat_id},
"from": {"id": 8},
"text": "/export все",
}
with patch("bot.send_text") as send_text, patch(
"bot.send_document"
) as send_document:
handle_command(connection, config, message)
send_text.assert_called_once_with(
config, message, "Экспорт доступен только владельцу бота."
)
send_document.assert_not_called()
def test_sends_report_to_reports_topic_and_marks_discussion(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-100123,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({2}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
message = {
"message_id": 12,
"message_thread_id": 2,
"date": 1,
"chat": {"id": config.chat_id},
"from": {"id": config.owner_id},
"text": "/export",
}
with patch("bot.send_text") as send_text, patch(
"bot.send_document", return_value={"message_id": 99}
) as send_document:
handle_command(connection, config, message)
document = send_document.call_args.args
self.assertEqual(document[1], config.report_thread_id)
self.assertIn("#report_12", document[4])
self.assertIn("https://t.me/c/123/12", document[4])
marker = send_text.call_args.args[2]
self.assertIn("ОБСУЖДЕНИЕ ЗАВЕРШЕНО", marker)
self.assertIn("#report_12", marker)
self.assertIn("https://t.me/c/123/99", marker)
def test_next_export_starts_after_previous_cutoff(self):
with tempfile.TemporaryDirectory() as directory:
config = Config(
token="test",
chat_id=-100123,
owner_id=7,
report_thread_id=37,
thread_ids=frozenset({2}),
database=Path(directory) / "bot.sqlite3",
timezone=ZoneInfo("UTC"),
)
connection = connect(config.database)
def archive(message_id, text):
archive_update(
connection,
{
"update_id": message_id,
"message": {
"message_id": message_id,
"message_thread_id": 2,
"date": message_id,
"chat": {"id": config.chat_id},
"from": {"id": config.owner_id},
"text": text,
},
},
config,
)
archive(10, "Первое обсуждение")
with patch("bot.send_text"), patch(
"bot.send_document",
side_effect=[{"message_id": 90}, {"message_id": 91}],
) as send_document:
handle_command(
connection,
config,
{
"message_id": 12,
"message_thread_id": 2,
"from": {"id": config.owner_id},
"text": "/export",
},
)
archive(13, "Второе обсуждение")
handle_command(
connection,
config,
{
"message_id": 14,
"message_thread_id": 2,
"from": {"id": config.owner_id},
"text": "/export",
},
)
first_export = send_document.call_args_list[0].args[3].decode()
second_export = send_document.call_args_list[1].args[3].decode()
self.assertIn("Первое обсуждение", first_export)
self.assertNotIn("Первое обсуждение", second_export)
self.assertIn("Второе обсуждение", second_export)
if __name__ == "__main__":
unittest.main()