first commit

This commit is contained in:
2026-05-25 01:12:43 +03:00
commit bfc22efe24
83 changed files with 8903 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.venv
venv
__pycache__
*.pyc
*.pyo
volumes
**/__pycache__
+42
View File
@@ -0,0 +1,42 @@
# Python
__pycache__/
*.py[cod]
*.so
# Virtualenvs
.venv/
venv/
# Tool caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
.coverage.*
htmlcov/
# Editors / OS
.idea/
.vscode/
.DS_Store
# Build artifacts
build/
dist/
*.egg-info/
# Local environment files
.env
.env.*
**/.env
**/.env.*
postgres.env
!api/.env.example
!bot/.env.example
!postgres.env.example
# Runtime data
volumes/
# Local protocol artifacts
protocol/.xdp-*
+132
View File
@@ -0,0 +1,132 @@
# LawBot
Telegram-бот с RAG-поиском по законам РФ.
## Что внутри
- `bot/` — Telegram-бот на `aiogram`
- `api/` — FastAPI-сервис для retrieval и генерации ответа
- `parser/` — CLI-парсер и загрузка законов
- `shared/` — общий слой БД и модели
- `compose.yml` — запуск всего стека через Docker Compose
## Стек
- Telegram Bot API
- FastAPI
- PostgreSQL
- Redis
- ChromaDB
- OpenRouter
- локальная embedding-модель на CPU
## Подготовка
Создай рабочие env-файлы из примеров:
```bash
cp bot/.env.example bot/.env
cp api/.env.example api/.env
cp postgres.env.example postgres.env
```
Заполни:
- `bot/.env``TOKEN`, `BASE_ADMIN`, при необходимости `TELEGRAM_BOT_PROXY`
- `api/.env``OPENAI_API_KEY`
- `postgres.env` — логин/пароль БД
## Запуск
```bash
docker compose up -d --build
```
Проверка API:
```bash
curl http://127.0.0.1:8080/health
```
## Загрузка законов
Полный прогон:
```bash
docker compose exec tgbot python -m parser run
```
Запуск только для одной категории:
```bash
docker compose exec tgbot python -m parser run --categories labor --limit 1
```
Отдельные шаги:
```bash
docker compose exec tgbot python -m parser discover
docker compose exec tgbot python -m parser fetch
docker compose exec tgbot python -m parser normalize
docker compose exec tgbot python -m parser ingest
```
## Индексация Chroma
После нового `parser ingest` перестрой индекс:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/index/rebuild \
-H 'Content-Type: application/json' \
-d '{"reset_collection": true}'
```
Индексация только нужного домена:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/index/rebuild \
-H 'Content-Type: application/json' \
-d '{"law_types":["criminal"],"reset_collection":false}'
```
## Полезные команды
Проверить логи:
```bash
docker compose logs -f tgbot
docker compose logs -f api
```
Проверить поиск:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/rag/search \
-H 'Content-Type: application/json' \
-d '{
"question":"Работодатель задержал зарплату. Что делать?",
"category":"Работа",
"region":"Москва",
"top_k":3
}'
```
Проверить полный ответ:
```bash
curl -X POST http://127.0.0.1:8080/api/v1/rag/answer \
-H 'Content-Type: application/json' \
-d '{
"question":"Работодатель задержал зарплату. Что делать?",
"category":"Работа",
"region":"Москва",
"top_k":3
}'
```
## Данные
- PostgreSQL: `./volumes/postgres`
- ChromaDB: `./volumes/chroma`
- Hugging Face cache: `./volumes/huggingface`
- Parser artifacts: `./volumes/parser`
+487
View File
@@ -0,0 +1,487 @@
# SYSTEM AUDIT: LawBot
## 1. Что это за проект
`LawBot` — это Telegram-бот, который:
1. принимает вопрос пользователя;
2. ищет подходящие нормы закона РФ;
3. достает текст из своей базы;
4. передает найденные фрагменты в LLM;
5. возвращает пользователю ответ простыми словами.
Проект состоит из 4 основных частей:
| Часть | Папка | Что делает |
|---|---|---|
| Telegram-бот | `bot/` | Общается с пользователем в Telegram |
| API для RAG | `api/` | Делает поиск по законам и генерацию ответа |
| Парсер законов | `parser/` | Скачивает и нормализует документы |
| Общий слой БД | `shared/` | Хранит модели БД и методы работы с БД |
---
## 2. Архитектура проекта
### 2.1. Общая схема
```mermaid
flowchart LR
User["Пользователь Telegram"]
Bot["bot/\nAiogram bot"]
Api["api/\nFastAPI RAG API"]
Parser["parser/\nCLI parser"]
Shared["shared/\nDB layer"]
Pg["PostgreSQL"]
Redis["Redis"]
Chroma["ChromaDB"]
LLM["OpenRouter LLM"]
Embed["Local embedding model"]
Consultant["consultant.ru"]
User --> Bot
Bot --> Api
Bot --> Shared
Bot --> Redis
Api --> Shared
Api --> Chroma
Api --> LLM
Api --> Embed
Shared --> Pg
Parser --> Consultant
Parser --> Shared
Parser --> Pg
```
### 2.2. Что запускается в Docker Compose
Файл: [compose.yml](/home/ruby/Desktop/DockerProjects/LawBot/compose.yml:1)
| Сервис | Образ / Dockerfile | Назначение | Порт |
|---|---|---|---|
| `tgbot` | `bot/Dockerfile` | Telegram-бот | нет внешнего порта |
| `api` | `api/Dockerfile` | FastAPI RAG API | `8080` |
| `postgredb` | `postgres:16-alpine` | Основная БД | нет внешнего порта |
| `redisdb` | `redis:6-alpine` | FSM/storage для aiogram | нет внешнего порта |
| `chromadb` | `chromadb/chroma:1.0.12` | Векторный поиск | `8000` |
Важно: отдельного контейнера для `parser/` нет. Парсер запускается командой внутри контейнера `tgbot`.
---
## 3. Структура директорий
### 3.1. Корень проекта
| Путь | Назначение |
|---|---|
| `compose.yml` | Запуск всех сервисов |
| `README.md` | Краткая инструкция по запуску |
| `mvp.md` | Текстовое описание MVP |
| `postgres.env` | Настройки подключения к PostgreSQL |
| `postgres.env.example` | Пример env для PostgreSQL |
| `SYSTEM_AUDIT.md` | Этот аудит |
| `volumes/` | Постоянные данные контейнеров |
| `protocol/` | Документы для курсовой |
### 3.2. Папка `api/`
| Путь | Назначение |
|---|---|
| `api/main.py` | Точка входа FastAPI |
| `api/config.py` | Чтение env-переменных API |
| `api/deps.py` | Создание singleton-зависимостей |
| `api/schemas.py` | Pydantic-схемы API |
| `api/logging.py` | Настройка логов API |
| `api/routers/` | HTTP-роуты (`health`, `indexing`, `rag`) |
| `api/services/` | Основная логика: embeddings, retrieval, indexing, LLM |
| `api/clients/` | Клиенты для OpenRouter и Chroma |
| `api/prompts/` | Промпты для классификации и ответа |
| `api/requirements.txt` | Зависимости API |
| `api/Dockerfile` | Сборка контейнера API |
### 3.3. Папка `bot/`
| Путь | Назначение |
|---|---|
| `bot/aiogram_run.py` | Точка входа Telegram-бота |
| `bot/create_bot.py` | Создание Bot / Dispatcher / Redis / ORM |
| `bot/handlers/` | Обработчики команд, кнопок и сценариев |
| `bot/keyboards/` | Reply и inline клавиатуры |
| `bot/middlewares/` | Антифлуд, blacklist, album middleware |
| `bot/states/` | FSM состояния aiogram |
| `bot/utils/rag_api.py` | HTTP-клиент бота для вызова `api` |
| `bot/utils/text_tools.py` | Форматирование и нарезка текста для Telegram |
| `bot/templates/` | Шаблоны файлов (`users.xlsx`) |
| `bot/requirements.txt` | Зависимости бота |
| `bot/Dockerfile` | Сборка контейнера бота |
| `bot/webhooks.py` | Отдельный тестовый FastAPI webhook, не подключен к `compose.yml` |
### 3.4. Папка `parser/`
| Путь | Назначение |
|---|---|
| `parser/__main__.py` | Позволяет запускать `python -m parser` |
| `parser/cli.py` | CLI-команды `discover/fetch/normalize/ingest/run` |
| `parser/config.py` | Список целевых документов и пути сохранения |
| `parser/discovery.py` | Проверка категорий на `consultant.ru/popular/` |
| `parser/fetcher.py` | Скачивание root HTML и страниц статей |
| `parser/normalizer.py` | Разбор HTML и сборка нормализованного JSON |
| `parser/ingest.py` | Запись документов и чанков в PostgreSQL |
| `parser/utils.py` | Общие функции парсера |
### 3.5. Папка `shared/`
| Путь | Назначение |
|---|---|
| `shared/models.py` | SQLAlchemy-модели |
| `shared/repositories.py` | Класс `ORM` и методы работы с БД |
| `shared/engine.py` | Создание async engine и sessionmaker |
| `shared/types.py` | Вспомогательные типы |
| `shared/__init__.py` | Единая точка импорта DB-слоя |
### 3.6. Папка `protocol/`
| Путь | Назначение |
|---|---|
| `protocol/generate_protocol_docx.py` | Генератор `.docx` протокола курсовой |
| `protocol/Протокол_курсовой_работы_LawBot.docx` | Готовый документ |
| `protocol/task/` | Входные материалы задания |
### 3.7. Папка `volumes/`
| Путь | Назначение |
|---|---|
| `volumes/postgres/` | Данные PostgreSQL |
| `volumes/chroma/` | Данные ChromaDB |
| `volumes/huggingface/` | Кеш embedding-модели |
| `volumes/parser/` | Артефакты парсера: raw, normalized, state |
---
## 4. Ключевые сервисы, модули и точки входа
### 4.1. Точки входа
| Компонент | Точка входа | Что происходит |
|---|---|---|
| Telegram-бот | [bot/aiogram_run.py](/home/ruby/Desktop/DockerProjects/LawBot/bot/aiogram_run.py:1) | Поднимает bot, middleware, routers и polling |
| FastAPI API | [api/main.py](/home/ruby/Desktop/DockerProjects/LawBot/api/main.py:1) | Поднимает API, проверяет БД, запускает автоиндексацию |
| Парсер | [parser/__main__.py](/home/ruby/Desktop/DockerProjects/LawBot/parser/__main__.py:1) -> [parser/cli.py](/home/ruby/Desktop/DockerProjects/LawBot/parser/cli.py:1) | Запускает CLI пайплайн |
### 4.2. Основные модули API
| Модуль | Файл | Роль |
|---|---|---|
| Конфиг | `api/config.py` | Читает env и хранит настройки |
| Роуты | `api/routers/rag.py` | `/search` и `/answer` |
| Индексация | `api/routers/indexing.py` | `/api/v1/index/rebuild` |
| Retrieval | `api/services/retrieval.py` | Гибридный поиск: Postgres FTS + Chroma |
| Индексация | `api/services/indexing.py` | Перенос чанков из Postgres в Chroma |
| Embeddings | `api/services/local_embeddings.py` | Локальные эмбеддинги через `sentence-transformers` |
| LLM-логика | `api/services/legal_ai.py` | Классификация, генерация ответа, заголовка консультации |
| Векторное хранилище | `api/clients/chroma_store.py` | Работа с Chroma |
| LLM-клиент | `api/clients/openrouter_client.py` | AsyncOpenAI-клиент для OpenRouter |
### 4.3. Основные модули бота
| Модуль | Файл | Роль |
|---|---|---|
| Создание бота | `bot/create_bot.py` | Bot, Dispatcher, RedisStorage, ORM, timezone |
| Главный запуск | `bot/aiogram_run.py` | Сборка всех роутеров |
| Пользовательский сценарий | `bot/handlers/client/main.py` | Вопрос -> регион -> ответ -> история |
| Админка | `bot/handlers/admin/*.py` | Статистика, blacklist, рассылка, настройки |
| HTTP-клиент к API | `bot/utils/rag_api.py` | Отправляет запросы в FastAPI |
| Форматирование | `bot/utils/text_tools.py` | Подготовка текста под Telegram HTML |
### 4.4. Основные модули парсера
| Модуль | Файл | Роль |
|---|---|---|
| Discovery | `parser/discovery.py` | Собирает manifest целевых документов |
| Fetch | `parser/fetcher.py` | Скачивает страницы документов |
| Normalize | `parser/normalizer.py` | Превращает HTML в нормализованный JSON |
| Ingest | `parser/ingest.py` | Пишет `law_sources` и `law_chunks` в БД |
### 4.5. Основной DB-слой
| Модуль | Файл | Роль |
|---|---|---|
| ORM | `shared/repositories.py` | Все операции с PostgreSQL |
| Модели | `shared/models.py` | Таблицы пользователей, консультаций, законов, чанков |
---
## 5. Кто за что отвечает
| Сервис / модуль | Отвечает за |
|---|---|
| `tgbot` | UI для пользователя и администратора |
| `api` | Поиск по законам и генерацию ответа |
| `parser` | Наполнение базы законами |
| `shared` | Единый доступ к PostgreSQL |
| `postgredb` | Хранение пользователей, консультаций, законов и чанков |
| `redisdb` | Хранение FSM-состояний aiogram |
| `chromadb` | Векторный индекс чанков |
| `OpenRouter` | LLM для классификации и ответа |
| Локальная embedding-модель | Преобразование текста в векторы |
---
## 6. Переменные окружения
### 6.1. `bot/.env`
Источник: [bot/.env.example](/home/ruby/Desktop/DockerProjects/LawBot/bot/.env.example:1)
| Переменная | Назначение |
|---|---|
| `TOKEN` | Telegram bot token |
| `TELEGRAM_BOT_PROXY` | Прокси для Telegram Bot API |
| `BASE_ADMIN` | Telegram ID администратора |
| `RAG_API_URL` | URL FastAPI сервиса |
| `REDIS_URL` | Подключение к Redis |
| `POSTGRES_DB` | Имя БД |
| `POSTGRES_USER` | Логин БД |
| `POSTGRES_PASSWORD` | Пароль БД |
| `POSTGRES_HOST` | Хост PostgreSQL |
| `POSTGRES_PORT` | Порт PostgreSQL |
| `TIMEZONE` | Часовой пояс для отображения дат |
### 6.2. `api/.env`
Источник: [api/.env.example](/home/ruby/Desktop/DockerProjects/LawBot/api/.env.example:1)
| Переменная | Назначение |
|---|---|
| `OPENAI_BASE_URL` | Базовый URL OpenRouter |
| `OPENAI_API_KEY` | Ключ доступа к OpenRouter |
| `LLM_MODEL` | Модель LLM для классификации и ответа |
| `EMBEDDING_MODEL` | Локальная embedding-модель |
| `EMBEDDING_DEVICE` | Устройство (`cpu`) |
| `CHROMA_HOST` | Хост ChromaDB |
| `CHROMA_PORT` | Порт ChromaDB |
| `CHROMA_COLLECTION` | Название коллекции Chroma |
| `CHROMA_SSL` | Использовать ли SSL |
| `RAG_TOP_K` | Дефолтное число результатов RAG |
| `FTS_TOP_K` | Сколько брать из full-text поиска |
| `VECTOR_TOP_K` | Сколько брать из vector search |
| `INDEX_BATCH_SIZE` | Размер батча для индексации и embeddings |
| `LLM_TIMEOUT_SECONDS` | Таймаут LLM-запросов |
| `API_HOST` | Хост FastAPI |
| `API_PORT` | Порт FastAPI |
| `LOG_LEVEL` | Уровень логов |
| `AUTO_INDEX_ON_STARTUP` | Запускать ли автоиндексацию при старте API |
| `AUTO_INDEX_ONLY_IF_EMPTY` | Индексировать только если Chroma пустая/не синхронизирована |
| `AUTO_INDEX_RESET_COLLECTION` | Сбрасывать ли коллекцию при автоиндексации |
| `AUTO_INDEX_RETRY_DELAY_SECONDS` | Пауза между попытками автоиндексации |
| `AUTO_INDEX_MAX_ATTEMPTS` | Максимум попыток автоиндексации |
### 6.3. `postgres.env`
Источник: [postgres.env.example](/home/ruby/Desktop/DockerProjects/LawBot/postgres.env.example:1)
| Переменная | Назначение |
|---|---|
| `POSTGRES_DB` | Имя БД PostgreSQL |
| `POSTGRES_USER` | Пользователь БД |
| `POSTGRES_PASSWORD` | Пароль БД |
| `POSTGRES_HOST` | Хост БД |
| `POSTGRES_PORT` | Порт БД |
---
## 7. Основные таблицы данных
Источник: [shared/models.py](/home/ruby/Desktop/DockerProjects/LawBot/shared/models.py:1)
| Таблица | Назначение |
|---|---|
| `users` | Пользователи Telegram |
| `admins` | Администраторы |
| `blacklist` | Заблокированные пользователи |
| `settings` | Настройки бота |
| `consultations` | Диалоги пользователя с ботом |
| `messages` | Сообщения внутри консультаций |
| `rag_queries` | История поисковых запросов и найденных чанков |
| `law_sources` | Документы законов |
| `law_chunks` | Чанки статей законов |
---
## 8. Потоки данных
### 8.1. Поток ответа пользователю
1. Пользователь пишет в Telegram.
2. `bot/handlers/client/main.py` принимает сообщение.
3. Бот вызывает `bot/utils/rag_api.py`.
4. HTTP-запрос уходит в `POST /api/v1/rag/answer`.
5. `api/services/legal_ai.py` классифицирует вопрос.
6. `api/services/retrieval.py` ищет чанки:
- через PostgreSQL full-text search;
- через Chroma vector search.
7. `api/services/legal_ai.py` генерирует ответ через OpenRouter.
8. Если `save_history=true`, API сохраняет:
- консультацию;
- сообщения;
- rag-запрос.
9. Бот получает ответ, форматирует его и отправляет в Telegram.
### 8.2. Поток загрузки законов
1. Команда `python -m parser run`.
2. `parser/discovery.py` собирает manifest.
3. `parser/fetcher.py` скачивает root HTML и статьи.
4. `parser/normalizer.py` делает нормализованный JSON.
5. `parser/ingest.py` записывает документы и чанки в PostgreSQL.
6. `api/services/indexing.py` переносит чанки в Chroma.
### 8.3. Схема зависимости модулей
```text
bot/handlers/client/main.py
-> bot/utils/rag_api.py
-> api/routers/rag.py
-> api/services/legal_ai.py
-> api/services/retrieval.py
-> shared/repositories.py
-> PostgreSQL / ChromaDB / OpenRouter
parser/cli.py
-> parser/discovery.py
-> parser/fetcher.py
-> parser/normalizer.py
-> parser/ingest.py
-> shared/repositories.py
-> PostgreSQL
```
---
## 9. Используемые технологии и зависимости
### 9.1. По сервисам
| Область | Технологии |
|---|---|
| Bot | `aiogram`, `aiohttp`, `httpx`, `redis`, `uvloop` |
| API | `FastAPI`, `uvicorn`, `openai`, `chromadb`, `sentence-transformers`, `SQLAlchemy`, `asyncpg` |
| Parser | `requests`, `beautifulsoup4` |
| DB | `PostgreSQL`, `SQLAlchemy`, `asyncpg` |
| Vector search | `ChromaDB` |
| LLM | `OpenRouter` через `AsyncOpenAI` |
| Embeddings | `sentence-transformers` + локальная модель |
### 9.2. Замеченные версии
Источники:
- [api/requirements.txt](/home/ruby/Desktop/DockerProjects/LawBot/api/requirements.txt:1)
- [bot/requirements.txt](/home/ruby/Desktop/DockerProjects/LawBot/bot/requirements.txt:1)
| Пакет | Версия |
|---|---|
| `fastapi` | `0.115.9` |
| `uvicorn` | `0.34.2` |
| `openai` | `1.82.0` |
| `chromadb` | `1.0.12` |
| `SQLAlchemy` | `2.0.38` |
| `asyncpg` | `0.30.0` |
| `sentence-transformers` | `5.1.2` |
| `aiogram` | `3.17.0` |
| `httpx` | `0.28.1` |
| `redis` | `5.2.1` |
| `beautifulsoup4` | `4.13.4` |
---
## 10. Что выглядит слабо или странно
Ниже только то, что видно по текущему коду.
| Проблема | Где видно | Почему это слабое место |
|---|---|---|
| Нет отдельного сервиса `parser` | `compose.yml` | Парсер живет внутри контейнера бота. Это смешивает роли и зависимости. |
| Нет миграций БД | `shared/repositories.py:97` | Схема создается через `create_all()` и `ALTER TABLE`, что плохо масштабируется. |
| Один большой репозиторий БД | `shared/repositories.py` | Класс `ORM` содержит слишком много обязанностей: users, laws, consultations, search. |
| Один большой сервис LLM | `api/services/legal_ai.py` | В одном файле смешаны классификация, ответ, fallback, sanitizing и генерация заголовка. |
| `bot/requirements.txt` содержит лишнее | `bot/requirements.txt` | Там есть `fastapi`, `uvicorn`, parser-зависимости. Контейнер бота тяжелее, чем должен быть. |
| `cfg_loader.py` выглядит мертвым кодом | `bot/utils/cfg_loader.py` | Ищет `cfg/config.json`, но такой папки в проекте нет и импортов почти нет. |
| `bot/webhooks.py` не подключен | `bot/webhooks.py`, `compose.yml` | В проекте есть отдельный FastAPI webhook-файл, но он не участвует в основном запуске. |
| Нет тестов | по структуре файлов | В проекте не найдено папки `tests/` или явных unit/integration тестов. |
| Парсер завязан на HTML-структуру сайта | `parser/fetcher.py`, `parser/normalizer.py` | Любое изменение верстки `consultant.ru` может сломать парсинг. |
| Full-text поиск только на русском tsvector | `shared/repositories.py` | Это нормально для РФ, но поиск по сложным формулировкам может быть нестабилен без доп. нормализации. |
| В `api` автоиндексация идет при старте | `api/main.py` | Удобно для MVP, но на большом объеме это может тормозить старт сервиса. |
| В репозитории есть `.venv/` | структура проекта | Локальное окружение лежит внутри проекта. Это легко случайно потащить в архив или deploy-контекст. |
### 10.1. Неясные или частично подтвержденные зоны
| Наблюдение | Что именно неясно |
|---|---|
| Админская часть | Полный функционал не анализировался построчно во всех admin handlers |
| `protocol/` | Это не runtime-часть приложения, а документы для курсовой |
| `bot/keyboards/inline_keyboards.py` | Файл существует, но по структуре почти пустой и не играет заметной роли |
---
## 11. Рекомендации по улучшению структуры
### 11.1. Что стоит сделать в первую очередь
| Рекомендация | Зачем |
|---|---|
| Выделить `parser` в отдельный контейнер | Чтобы бот не таскал лишние parser-зависимости |
| Ввести миграции (`alembic`) | Чтобы схема БД менялась предсказуемо |
| Разбить `shared/repositories.py` на несколько репозиториев | Например: `users`, `consultations`, `laws`, `rag` |
| Разбить `api/services/legal_ai.py` | Отдельно: classifier, answer renderer, title generator, fallback |
| Добавить `tests/` | Минимум smoke-тесты для `api`, `parser`, `shared` |
| Вынести parser-зависимости из `bot/requirements.txt` | Уменьшить размер и сложность контейнера бота |
### 11.2. Что улучшит понятность проекта
| Рекомендация | Зачем |
|---|---|
| Добавить `docs/architecture.md` | Новичку будет проще понять проект |
| Сделать единый `settings`-слой и для `bot`, и для `parser` | Сейчас конфигурация читается разными способами |
| Удалить или подключить мертвые файлы | Например `bot/webhooks.py`, `bot/utils/cfg_loader.py` |
| Добавить схему “как идет запрос” в README | Снизит порог входа |
### 11.3. Что важно для production
| Рекомендация | Зачем |
|---|---|
| Ограничить и логировать ошибки внешних API | OpenRouter и Consultant могут вести себя нестабильно |
| Добавить rate limiting и retry policy на уровне API-клиентов | Улучшит устойчивость |
| Отдельно мониторить время поиска и генерации | Позволит понять, где тормозит система |
| Добавить backup-стратегию для `volumes/postgres` и `volumes/chroma` | Сейчас данные локально персистентны, но не защищены от потери |
---
## 12. Краткий вывод
Проект уже собран как рабочий MVP:
- есть Telegram-бот;
- есть FastAPI-сервис для RAG;
- есть парсер законов;
- есть PostgreSQL и ChromaDB;
- есть единый DB-слой.
Главная архитектурная идея понятная:
1. `parser` наполняет PostgreSQL законами;
2. `api` строит векторный индекс и отвечает на вопросы;
3. `bot` общается с пользователем;
4. `shared` соединяет все это с БД.
Главные слабые места сейчас:
- смешение ролей `bot` и `parser`;
- отсутствие миграций;
- слишком крупные файлы `shared/repositories.py` и `api/services/legal_ai.py`;
- отсутствие автоматических тестов.
Для новичка проект уже читаемый, но поддерживать его дальше будет проще после разделения больших модулей на более мелкие.
+35
View File
@@ -0,0 +1,35 @@
# OpenRouter endpoint and API key for LLM calls
OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_API_KEY=sk-or-v1-replace_me
# Main LLM used for classification and final answer generation
LLM_MODEL=openai/gpt-4.1-mini
# Local embedding model for Chroma indexing/search
EMBEDDING_MODEL=deepvk/USER2-small
EMBEDDING_DEVICE=cpu
# Chroma connection settings
CHROMA_HOST=chromadb
CHROMA_PORT=8000
CHROMA_COLLECTION=law_chunks
CHROMA_SSL=false
# Retrieval settings
RAG_TOP_K=5
FTS_TOP_K=20
VECTOR_TOP_K=20
INDEX_BATCH_SIZE=16
LLM_TIMEOUT_SECONDS=90
# FastAPI bind settings
API_HOST=0.0.0.0
API_PORT=8080
LOG_LEVEL=INFO
# Background indexing on startup
AUTO_INDEX_ON_STARTUP=true
AUTO_INDEX_ONLY_IF_EMPTY=true
AUTO_INDEX_RESET_COLLECTION=false
AUTO_INDEX_RETRY_DELAY_SECONDS=15
AUTO_INDEX_MAX_ATTEMPTS=20
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONPATH=/app
ENV HF_HOME=/root/.cache/huggingface
ENV CUDA_VISIBLE_DEVICES=""
COPY api/requirements.txt /app/api-requirements.txt
RUN pip install --no-cache-dir --extra-index-url https://download.pytorch.org/whl/cpu torch==2.7.0 && \
pip install --no-cache-dir -r /app/api-requirements.txt
COPY . /app
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8080"]
+1
View File
@@ -0,0 +1 @@
"""FastAPI RAG service package."""
+1
View File
@@ -0,0 +1 @@
"""API clients."""
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import logging
from typing import Any
import chromadb
from chromadb.config import Settings as ChromaSettings
from api.config import settings
logger = logging.getLogger(__name__)
class ChromaVectorStore:
def __init__(self) -> None:
self._client = chromadb.HttpClient(
host=settings.chroma_host,
port=settings.chroma_port,
ssl=settings.chroma_ssl,
settings=ChromaSettings(anonymized_telemetry=False),
)
self._collection_name = settings.chroma_collection
logger.info(
"Chroma client configured: host=%s port=%s collection=%s ssl=%s",
settings.chroma_host,
settings.chroma_port,
self._collection_name,
settings.chroma_ssl,
)
@property
def collection_name(self) -> str:
return self._collection_name
def get_collection(self):
return self._client.get_or_create_collection(
name=self._collection_name,
metadata={"hnsw:space": "cosine"},
)
def count(self) -> int:
return int(self.get_collection().count())
def reset_collection(self) -> None:
logger.warning("Resetting Chroma collection: %s", self._collection_name)
try:
self._client.delete_collection(self._collection_name)
except Exception:
logger.exception(
"Could not delete Chroma collection before reset, continuing with create_collection"
)
self.get_collection()
def upsert(
self,
ids: list[str],
documents: list[str],
embeddings: list[list[float]],
metadatas: list[dict[str, Any]],
) -> None:
logger.info(
"Upserting %s embeddings into Chroma collection=%s",
len(ids),
self._collection_name,
)
self.get_collection().upsert(
ids=ids,
documents=documents,
embeddings=embeddings,
metadatas=metadatas,
)
def query(
self,
query_embeddings: list[list[float]],
n_results: int,
) -> dict[str, Any]:
logger.info(
"Running vector query against collection=%s n_results=%s",
self._collection_name,
n_results,
)
return self.get_collection().query(
query_embeddings=query_embeddings,
n_results=n_results,
include=["distances", "metadatas", "documents"],
)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from openai import AsyncOpenAI
from api.config import settings
def build_openai_client() -> AsyncOpenAI:
return AsyncOpenAI(
api_key=settings.openai_api_key,
base_url=settings.openai_base_url,
timeout=settings.llm_timeout_seconds,
)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from dataclasses import dataclass
from decouple import config
def _to_bool(value: str) -> bool:
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Settings:
app_host: str = config("API_HOST", default="0.0.0.0")
app_port: int = config("API_PORT", cast=int, default=8080)
openai_base_url: str = config(
"OPENAI_BASE_URL", default="https://openrouter.ai/api/v1"
)
openai_api_key: str = config("OPENAI_API_KEY", default="")
llm_model: str = config("LLM_MODEL", default="openai/gpt-4.1-mini")
embedding_model: str = config(
"EMBEDDING_MODEL", default="deepvk/USER2-small"
)
embedding_device: str = config("EMBEDDING_DEVICE", default="cpu")
chroma_host: str = config("CHROMA_HOST", default="chromadb")
chroma_port: int = config("CHROMA_PORT", cast=int, default=8000)
chroma_collection: str = config("CHROMA_COLLECTION", default="law_chunks")
chroma_ssl: bool = _to_bool(config("CHROMA_SSL", default="false"))
rag_top_k: int = config("RAG_TOP_K", cast=int, default=5)
fts_top_k: int = config("FTS_TOP_K", cast=int, default=20)
vector_top_k: int = config("VECTOR_TOP_K", cast=int, default=20)
index_batch_size: int = config("INDEX_BATCH_SIZE", cast=int, default=32)
llm_timeout_seconds: int = config("LLM_TIMEOUT_SECONDS", cast=int, default=90)
log_level: str = config("LOG_LEVEL", default="INFO")
auto_index_on_startup: bool = _to_bool(config("AUTO_INDEX_ON_STARTUP", default="true"))
auto_index_only_if_empty: bool = _to_bool(
config("AUTO_INDEX_ONLY_IF_EMPTY", default="true")
)
auto_index_reset_collection: bool = _to_bool(
config("AUTO_INDEX_RESET_COLLECTION", default="false")
)
auto_index_retry_delay_seconds: int = config(
"AUTO_INDEX_RETRY_DELAY_SECONDS", cast=int, default=15
)
auto_index_max_attempts: int = config(
"AUTO_INDEX_MAX_ATTEMPTS", cast=int, default=20
)
settings = Settings()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from functools import lru_cache
from api.clients.chroma_store import ChromaVectorStore
from api.clients.openrouter_client import build_openai_client
from api.config import settings
from api.services.indexing import IndexingService
from api.services.legal_ai import LegalAIService
from api.services.local_embeddings import get_embedding_service
from api.services.retrieval import HybridRetrievalService
from shared import ORM
@lru_cache(maxsize=1)
def get_orm() -> ORM:
return ORM()
@lru_cache(maxsize=1)
def get_vector_store() -> ChromaVectorStore:
return ChromaVectorStore()
@lru_cache(maxsize=1)
def get_ai_service() -> LegalAIService:
return LegalAIService(build_openai_client(), settings.llm_model)
@lru_cache(maxsize=1)
def get_retrieval_service() -> HybridRetrievalService:
return HybridRetrievalService(
orm=get_orm(),
embedder=get_embedding_service(),
vector_store=get_vector_store(),
)
@lru_cache(maxsize=1)
def get_indexing_service() -> IndexingService:
return IndexingService(
orm=get_orm(),
embedder=get_embedding_service(),
vector_store=get_vector_store(),
)
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import logging
from api.config import settings
def configure_logging() -> None:
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("chromadb").setLevel(logging.INFO)
for logger_name in (
"chromadb.telemetry",
"chromadb.telemetry.product",
"chromadb.telemetry.product.posthog",
):
noisy_logger = logging.getLogger(logger_name)
noisy_logger.setLevel(logging.CRITICAL)
noisy_logger.propagate = False
noisy_logger.disabled = True
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import asyncio
from contextlib import asynccontextmanager
import logging
from time import perf_counter
from fastapi import FastAPI, Request
from api.config import settings
from api.deps import get_indexing_service, get_orm, get_vector_store
from api.logging import configure_logging
from api.routers.health import router as health_router
from api.routers.indexing import router as indexing_router
from api.routers.rag import router as rag_router
configure_logging()
logger = logging.getLogger(__name__)
async def run_startup_indexing_task() -> None:
if not settings.auto_index_on_startup:
logger.info("Startup auto-index is disabled")
return
indexing_service = get_indexing_service()
vector_store = get_vector_store()
for attempt in range(1, settings.auto_index_max_attempts + 1):
try:
current_count = vector_store.count()
db_chunk_count = await indexing_service.get_indexable_chunks_count()
should_reset_collection = settings.auto_index_reset_collection
logger.info(
"Startup auto-index check: attempt=%s/%s chroma_count=%s postgres_chunks=%s",
attempt,
settings.auto_index_max_attempts,
current_count,
db_chunk_count,
)
if db_chunk_count == 0:
logger.warning(
"No indexable chunks found in Postgres yet, retrying in %ss",
settings.auto_index_retry_delay_seconds,
)
await asyncio.sleep(settings.auto_index_retry_delay_seconds)
continue
if settings.auto_index_only_if_empty and current_count == db_chunk_count and current_count > 0:
logger.info(
"Skipping startup auto-index because Chroma is already in sync with Postgres: %s items",
current_count,
)
return
if current_count not in {0, db_chunk_count}:
should_reset_collection = True
logger.warning(
"Chroma/Postgres count mismatch detected, forcing collection reset: chroma_count=%s postgres_chunks=%s",
current_count,
db_chunk_count,
)
result = await indexing_service.rebuild(
reset_collection=should_reset_collection,
)
logger.info("Startup auto-index completed successfully: %s", result)
return
except asyncio.CancelledError:
logger.info("Startup auto-index task cancelled")
raise
except Exception:
logger.exception(
"Startup auto-index attempt %s/%s failed",
attempt,
settings.auto_index_max_attempts,
)
await asyncio.sleep(settings.auto_index_retry_delay_seconds)
logger.error(
"Startup auto-index exhausted all %s attempts",
settings.auto_index_max_attempts,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
orm = get_orm()
startup_index_task: asyncio.Task | None = None
logger.info("API startup initiated")
await orm.init_schema()
logger.info("Database schema is ready")
startup_index_task = asyncio.create_task(run_startup_indexing_task())
app.state.startup_index_task = startup_index_task
yield
if startup_index_task is not None and not startup_index_task.done():
startup_index_task.cancel()
try:
await startup_index_task
except asyncio.CancelledError:
pass
await orm.close()
logger.info("API shutdown completed")
app = FastAPI(title="LawBot RAG API", version="0.1.0", lifespan=lifespan)
@app.middleware("http")
async def log_requests(request: Request, call_next):
started_at = perf_counter()
logger.info(
"HTTP request started: method=%s path=%s client=%s",
request.method,
request.url.path,
request.client.host if request.client else "unknown",
)
try:
response = await call_next(request)
except Exception:
duration_ms = round((perf_counter() - started_at) * 1000, 2)
logger.exception(
"HTTP request failed: method=%s path=%s duration_ms=%s",
request.method,
request.url.path,
duration_ms,
)
raise
duration_ms = round((perf_counter() - started_at) * 1000, 2)
logger.info(
"HTTP request completed: method=%s path=%s status=%s duration_ms=%s",
request.method,
request.url.path,
response.status_code,
duration_ms,
)
return response
app.include_router(health_router)
app.include_router(indexing_router)
app.include_router(rag_router)
+1
View File
@@ -0,0 +1 @@
"""Prompt templates for the RAG API."""
+91
View File
@@ -0,0 +1,91 @@
CLASSIFIER_PROMPT = """Ты классификатор юридических вопросов по законам РФ.
Верни только JSON без markdown.
Поля:
- legal_domain
- issue_type
- jurisdiction
- region
- needs_clarification
- clarification_questions
- search_queries
- filters
Правила:
1. jurisdiction всегда RU.
2. Если данных недостаточно, needs_clarification = true.
3. search_queries должны быть пригодны для поиска по базе законов.
4. Не придумывай статьи.
5. Не давай юридический ответ на этом этапе.
6. filters.law_type заполняй только реальными доменами права, если уверен.
"""
ANSWER_PROMPT = """Ты юридический ИИ-консультант по законам РФ.
Твоя задача — подготовить структурированный ответ пользователю простым языком только на основании переданных норм закона.
Жесткие правила:
1. Используй только переданные фрагменты законов.
2. Не придумывай статьи, номера законов, судебную практику и сроки.
3. Если источников недостаточно, прямо скажи об этом.
4. Не обещай победу в суде.
5. Не выдавай себя за адвоката.
6. Не помогай обходить закон.
7. В конце добавь дисклеймер.
8. Не используй markdown, символы **, __, #, списки через `-` и другое markdown-оформление.
9. Пиши обычным текстом. Для акцентов используй короткие заголовки и нумерованные пункты.
10. Никогда не используй слова SOURCES, source, chunk, retrieval, база, векторный поиск, фрагменты, контекст.
11. Нельзя писать фразы вроде: "в ваших SOURCES", "по этим источникам", "на основании этих источников", "в базе нет", "в контексте нет".
12. Если данных не хватает, говори только по-человечески, например: "По тем нормам, которые удалось найти, прямого ответа на этот нюанс нет" или "В найденных нормах этот частный вопрос прямо не раскрыт".
13. Верни только JSON без markdown и без пояснений.
JSON schema:
{
"short_conclusion": "краткий вывод в 1-3 предложениях",
"legal_points": ["ключевая норма 1", "ключевая норма 2"],
"action_steps": ["практический шаг 1", "практический шаг 2"],
"risks": ["риск или ограничение 1", "риск или ограничение 2"]
}
"""
FOLLOW_UP_ANSWER_PROMPT = """Ты юридический ИИ-консультант по законам РФ.
Твоя задача — продолжить уже начатую консультацию и ответить пользователю простым, живым и естественным языком только на основании переданных норм закона.
Жесткие правила:
1. Используй только переданные нормы закона.
2. Учитывай историю консультации и отвечай именно на последний вопрос пользователя.
3. Не придумывай статьи, номера законов, судебную практику и сроки.
4. Если источников недостаточно, прямо скажи об этом.
5. Не обещай победу в суде.
6. Не выдавай себя за адвоката.
7. Не помогай обходить закон.
8. Не используй markdown и символы **, __, #, списки через `-`.
9. Не используй жесткий шаблон с разделами, если вопрос этого не требует.
10. Если уместно, можешь дать короткий пошаговый план.
11. В конце кратко укажи, на какие нормы ты опираешься, и добавь дисклеймер.
12. Никогда не используй слова SOURCES, source, chunk, retrieval, база, векторный поиск, фрагменты, контекст.
13. Нельзя писать фразы вроде: "в ваших SOURCES", "по этим источникам", "на основании этих источников", "в базе нет", "в контексте нет".
14. Если данных не хватает, формулируй это только естественным языком без упоминания внутренней кухни системы.
Формат ответа:
- свободный, разговорный, но деловой и понятный;
- без лишней воды;
- можно использовать короткие абзацы и списки;
- если вопрос уточняющий, отвечай прямо на него, а не повторяй всю предыдущую структуру заново.
"""
CONSULTATION_TITLE_PROMPT = """Ты помогаешь придумать короткий заголовок для юридической консультации.
Правила:
1. Верни только сам заголовок без кавычек, markdown и пояснений.
2. Заголовок должен быть коротким: 3-8 слов, максимум 70 символов.
3. Заголовок должен ясно отражать суть проблемы пользователя.
4. Не используй даты, обращения, вводные слова и канцеляризмы.
5. Не ставь точку в конце.
6. Пиши по-русски.
"""
+9
View File
@@ -0,0 +1,9 @@
fastapi==0.115.9
uvicorn==0.34.2
openai==1.82.0
chromadb==1.0.12
python-decouple==3.8
SQLAlchemy==2.0.38
asyncpg==0.30.0
pydantic==2.10.6
sentence-transformers==5.1.2
+1
View File
@@ -0,0 +1 @@
"""FastAPI routers."""
+15
View File
@@ -0,0 +1,15 @@
import logging
from fastapi import APIRouter
from api.schemas import HealthResponse
router = APIRouter(tags=["health"])
logger = logging.getLogger(__name__)
@router.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
logger.debug("Healthcheck request")
return HealthResponse(status="ok")
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter
from api.deps import get_indexing_service
from api.schemas import IndexRequest, IndexResponse
router = APIRouter(prefix="/api/v1/index", tags=["index"])
@router.post("/rebuild", response_model=IndexResponse)
async def rebuild_index(payload: IndexRequest) -> IndexResponse:
service = get_indexing_service()
result = await service.rebuild(
source_ids=payload.source_ids,
law_types=payload.law_types,
reset_collection=payload.reset_collection,
batch_size=payload.batch_size,
)
return IndexResponse(**result)
+185
View File
@@ -0,0 +1,185 @@
import logging
from fastapi import APIRouter, HTTPException
from api.deps import get_ai_service, get_orm, get_retrieval_service
from api.schemas import (
AnswerRequest,
AnswerResponse,
ClassificationResult,
RetrievedChunk,
SearchRequest,
SearchResponse,
)
from api.services.legal_ai import build_fallback_title, infer_law_types
router = APIRouter(prefix="/api/v1/rag", tags=["rag"])
logger = logging.getLogger(__name__)
@router.post("/search", response_model=SearchResponse)
async def search(payload: SearchRequest) -> SearchResponse:
logger.info(
"RAG search request: category=%s region=%s top_k=%s question_length=%s",
payload.category,
payload.region,
payload.top_k,
len(payload.question),
)
ai_service = get_ai_service()
retrieval = get_retrieval_service()
try:
classification = await ai_service.classify(
question=payload.question,
category=payload.category,
region=payload.region,
user_type=payload.user_type,
history=payload.history,
)
chunks = await retrieval.retrieve(
classification=classification,
fallback_law_types=payload.law_types or infer_law_types(payload.category),
top_k=payload.top_k,
)
except RuntimeError as exc:
logger.exception("RAG search failed")
raise HTTPException(status_code=502, detail=str(exc)) from exc
return SearchResponse(
classification=classification,
generated_queries=classification.search_queries,
retrieved_chunks=[RetrievedChunk(**chunk) for chunk in chunks],
)
@router.post("/answer", response_model=AnswerResponse)
async def answer(payload: AnswerRequest) -> AnswerResponse:
logger.info(
"RAG answer request: user_id=%s consultation_id=%s save_history=%s category=%s region=%s top_k=%s question_length=%s",
payload.user_id,
payload.consultation_id,
payload.save_history,
payload.category,
payload.region,
payload.top_k,
len(payload.question),
)
ai_service = get_ai_service()
retrieval = get_retrieval_service()
orm = get_orm()
try:
classification = await ai_service.classify(
question=payload.question,
category=payload.category,
region=payload.region,
user_type=payload.user_type,
history=payload.history,
)
chunks = await retrieval.retrieve(
classification=classification,
fallback_law_types=payload.law_types or infer_law_types(payload.category),
top_k=payload.top_k,
)
except RuntimeError as exc:
logger.exception("RAG answer failed on classification/retrieval stage")
raise HTTPException(status_code=502, detail=str(exc)) from exc
if not chunks:
logger.warning("RAG answer request returned no reliable chunks")
raise HTTPException(
status_code=404,
detail="No reliable law chunks were found for this question.",
)
try:
answer_text = await ai_service.answer(
question=payload.question,
category=payload.category,
region=payload.region,
user_type=payload.user_type,
history=payload.history,
sources=chunks,
)
except RuntimeError as exc:
logger.exception("RAG answer failed on generation stage")
raise HTTPException(status_code=502, detail=str(exc)) from exc
consultation_id = payload.consultation_id
user_message_id = None
assistant_message_id = None
if payload.save_history:
if payload.user_id is None:
raise HTTPException(
status_code=400,
detail="user_id is required when save_history=true",
)
user = await orm.get_user(payload.user_id)
if user is None:
raise HTTPException(
status_code=404,
detail="User was not found. Start the bot first so the profile is created.",
)
if consultation_id is not None:
consultation = await orm.get_consultation(
consultation_id=consultation_id,
user_id=payload.user_id,
)
if consultation is None:
raise HTTPException(
status_code=404,
detail="Consultation was not found for this user.",
)
if consultation_id is None:
try:
consultation_title = await ai_service.generate_consultation_title(
question=payload.question,
category=payload.category or classification.legal_domain,
answer=answer_text,
)
except RuntimeError:
logger.exception("Consultation title generation failed, using fallback title")
consultation_title = build_fallback_title(payload.question)
consultation = await orm.create_consultation(
user_id=payload.user_id,
category=payload.category or classification.legal_domain,
title=consultation_title,
region=payload.region or classification.region,
)
consultation_id = consultation.id
user_message = await orm.create_message(
consultation_id=consultation_id,
role="user",
content=payload.question,
)
user_message_id = user_message.id
await orm.create_rag_query(
consultation_id=consultation_id,
user_message_id=user_message_id,
generated_queries=classification.search_queries,
retrieved_chunks=chunks,
)
assistant_message = await orm.create_message(
consultation_id=consultation_id,
role="assistant",
content=answer_text,
sources_json=chunks,
)
assistant_message_id = assistant_message.id
return AnswerResponse(
classification=classification,
generated_queries=classification.search_queries,
retrieved_chunks=[RetrievedChunk(**chunk) for chunk in chunks],
answer=answer_text,
consultation_id=consultation_id,
user_message_id=user_message_id,
assistant_message_id=assistant_message_id,
)
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class HealthResponse(BaseModel):
status: str
class IndexRequest(BaseModel):
source_ids: list[int] | None = None
law_types: list[str] | None = None
reset_collection: bool = True
batch_size: int | None = None
class IndexResponse(BaseModel):
indexed_chunks: int
indexed_sources: int
collection_name: str
class SearchRequest(BaseModel):
question: str
category: str | None = None
region: str | None = None
user_type: str | None = None
history: list[dict[str, str]] = Field(default_factory=list)
law_types: list[str] | None = None
top_k: int = 5
class RetrievedChunk(BaseModel):
chunk_id: int
source_id: int
source_title: str
source_url: str | None = None
law_type: str | None = None
article_number: str | None = None
article_title: str | None = None
chunk_text: str
metadata: dict[str, Any] = Field(default_factory=dict)
score: float
class ClassificationResult(BaseModel):
legal_domain: str
issue_type: str
jurisdiction: str = "RU"
region: str | None = None
needs_clarification: bool = False
clarification_questions: list[str] = Field(default_factory=list)
search_queries: list[str] = Field(default_factory=list)
filters: dict[str, Any] = Field(default_factory=dict)
class StructuredInitialAnswer(BaseModel):
short_conclusion: str
legal_points: list[str] = Field(default_factory=list)
action_steps: list[str] = Field(default_factory=list)
risks: list[str] = Field(default_factory=list)
class SearchResponse(BaseModel):
classification: ClassificationResult
generated_queries: list[str]
retrieved_chunks: list[RetrievedChunk]
class AnswerRequest(BaseModel):
user_id: int | None = None
consultation_id: int | None = None
save_history: bool = False
question: str
category: str | None = None
region: str | None = None
user_type: str | None = None
history: list[dict[str, str]] = Field(default_factory=list)
law_types: list[str] | None = None
top_k: int = 5
class AnswerResponse(BaseModel):
classification: ClassificationResult
generated_queries: list[str]
retrieved_chunks: list[RetrievedChunk]
answer: str
consultation_id: int | None = None
user_message_id: int | None = None
assistant_message_id: int | None = None
+1
View File
@@ -0,0 +1 @@
"""Service layer for the RAG API."""
+126
View File
@@ -0,0 +1,126 @@
from __future__ import annotations
import logging
from fastapi.concurrency import run_in_threadpool
from api.clients.chroma_store import ChromaVectorStore
from api.config import settings
from api.services.local_embeddings import LocalEmbeddingService
from shared import ORM
logger = logging.getLogger(__name__)
def build_embedding_text(chunk: dict) -> str:
article_title = chunk.get("article_title") or ""
source_title = chunk.get("source_title") or ""
return "\n".join(
part
for part in [
f"source: {source_title}",
f"article: {article_title}",
f"text: {chunk['chunk_text']}",
]
if part.strip()
)
class IndexingService:
def __init__(
self,
orm: ORM,
embedder: LocalEmbeddingService,
vector_store: ChromaVectorStore,
) -> None:
self.orm = orm
self.embedder = embedder
self.vector_store = vector_store
async def get_indexable_chunks_count(
self,
source_ids: list[int] | None = None,
law_types: list[str] | None = None,
) -> int:
chunks = await self.orm.list_chunks_for_indexing(
source_ids=source_ids,
law_types=law_types,
active_only=True,
)
return len(chunks)
async def rebuild(
self,
source_ids: list[int] | None = None,
law_types: list[str] | None = None,
reset_collection: bool = True,
batch_size: int | None = None,
) -> dict:
logger.info(
"Index rebuild started: reset_collection=%s source_ids=%s law_types=%s",
reset_collection,
source_ids,
law_types,
)
chunks = await self.orm.list_chunks_for_indexing(
source_ids=source_ids,
law_types=law_types,
active_only=True,
)
logger.info("Loaded %s chunks from Postgres for indexing", len(chunks))
if reset_collection:
await run_in_threadpool(self.vector_store.reset_collection)
batch_size = batch_size or settings.index_batch_size
indexed_chunks = 0
indexed_sources = len({chunk["source_id"] for chunk in chunks})
for start in range(0, len(chunks), batch_size):
batch = chunks[start : start + batch_size]
batch_number = (start // batch_size) + 1
total_batches = max(1, (len(chunks) + batch_size - 1) // batch_size)
logger.info(
"Indexing batch %s/%s with %s chunks",
batch_number,
total_batches,
len(batch),
)
embeddings = await run_in_threadpool(
self.embedder.encode_documents,
[build_embedding_text(chunk) for chunk in batch],
)
await run_in_threadpool(
self.vector_store.upsert,
[str(chunk["chunk_id"]) for chunk in batch],
[chunk["chunk_text"] for chunk in batch],
embeddings,
[
{
"chunk_id": chunk["chunk_id"],
"source_id": chunk["source_id"],
"source_title": chunk["source_title"],
"source_url": chunk["source_url"],
"law_type": chunk["law_type"] or "",
"jurisdiction": chunk["jurisdiction"],
"article_number": chunk["article_number"] or "",
"article_title": chunk["article_title"] or "",
"version_hash": chunk["version_hash"],
}
for chunk in batch
],
)
indexed_chunks += len(batch)
logger.info(
"Indexed %s/%s chunks into Chroma",
indexed_chunks,
len(chunks),
)
result = {
"indexed_chunks": indexed_chunks,
"indexed_sources": indexed_sources,
"collection_name": self.vector_store.collection_name,
}
logger.info("Index rebuild completed: %s", result)
return result
+724
View File
@@ -0,0 +1,724 @@
from __future__ import annotations
import json
import logging
import re
from openai import AsyncOpenAI
from api.prompts.rag_prompts import (
ANSWER_PROMPT,
CLASSIFIER_PROMPT,
CONSULTATION_TITLE_PROMPT,
FOLLOW_UP_ANSWER_PROMPT,
)
from api.schemas import ClassificationResult, StructuredInitialAnswer
logger = logging.getLogger(__name__)
CATEGORY_MAP = {
"работа": ["labor"],
"труд": ["labor"],
"защита прав потребителей": ["consumer", "civil"],
"потребител": ["consumer", "civil"],
"жилье": ["housing", "civil", "mortgage"],
"аренда": ["housing", "civil"],
"семья": ["family"],
"долги": ["civil", "enforcement"],
"займы": ["civil"],
"договоры": ["civil"],
"договор": ["civil"],
"суд": ["procedural"],
"процесс": ["procedural"],
"административ": ["administrative"],
"уголов": ["criminal"],
"краж": ["criminal"],
"мошеннич": ["criminal"],
}
LAW_TYPE_ALIASES = {
"labor": "labor",
"труд": "labor",
"трудовое право": "labor",
"criminal": "criminal",
"уголов": "criminal",
"civil": "civil",
"граждан": "civil",
"договор": "civil",
"consumer": "consumer",
"защита прав потребителей": "consumer",
"потребител": "consumer",
"housing": "housing",
"жилищ": "housing",
"аренда": "housing",
"family": "family",
"семейн": "family",
"procedural": "procedural",
"процесс": "procedural",
"суд": "procedural",
"administrative": "administrative",
"административ": "administrative",
"enforcement": "enforcement",
"исполнительн": "enforcement",
"mortgage": "mortgage",
"ипотек": "mortgage",
}
INITIAL_ANSWER_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "lawbot_initial_answer",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"short_conclusion": {"type": "string"},
"legal_points": {
"type": "array",
"items": {"type": "string"},
},
"action_steps": {
"type": "array",
"items": {"type": "string"},
},
"risks": {
"type": "array",
"items": {"type": "string"},
},
},
"required": [
"short_conclusion",
"legal_points",
"action_steps",
"risks",
],
},
},
}
CLASSIFIER_RESPONSE_FORMAT = {
"type": "json_schema",
"json_schema": {
"name": "lawbot_classifier",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"legal_domain": {"type": "string"},
"issue_type": {"type": "string"},
"jurisdiction": {"type": "string"},
"region": {
"type": ["string", "null"],
},
"needs_clarification": {"type": "boolean"},
"clarification_questions": {
"type": "array",
"items": {"type": "string"},
},
"search_queries": {
"type": "array",
"items": {"type": "string"},
},
"filters": {
"type": "object",
"additionalProperties": False,
"properties": {
"law_type": {
"type": ["array", "null"],
"items": {"type": "string"},
},
},
},
},
"required": [
"legal_domain",
"issue_type",
"jurisdiction",
"region",
"needs_clarification",
"clarification_questions",
"search_queries",
"filters",
],
},
},
}
def extract_json(content: str, purpose: str = "response") -> dict:
try:
return json.loads(content)
except json.JSONDecodeError:
match = re.search(r"\{.*\}", content, re.S)
if not match:
logger.error("LLM %s returned non-JSON content: %s", purpose, content)
raise RuntimeError(f"LLM {purpose} returned invalid JSON.")
try:
return json.loads(match.group(0))
except json.JSONDecodeError as exc:
logger.error("LLM %s returned malformed JSON fragment: %s", purpose, content)
raise RuntimeError(f"LLM {purpose} returned malformed JSON.") from exc
def looks_like_llm_refusal(content: str) -> bool:
normalized = " ".join(content.lower().split())
refusal_markers = (
"i cannot assist",
"i can't assist",
"i cannot help",
"i'm sorry, but i cannot",
"не могу помочь с этим",
"не могу помочь в этом",
"не могу содействовать",
"не могу помочь с запросом",
"не могу ответить на этот запрос",
)
return any(marker in normalized for marker in refusal_markers)
def infer_law_types(category: str | None) -> list[str] | None:
if not category:
return None
normalized = category.lower().strip()
for key, law_types in CATEGORY_MAP.items():
if key in normalized:
return law_types
return None
def normalize_law_type_values(value) -> list[str] | None:
if value is None:
return None
raw_values = value if isinstance(value, list) else [value]
normalized_values: list[str] = []
for raw_value in raw_values:
if not isinstance(raw_value, str):
continue
raw_normalized = raw_value.strip().lower()
for alias, code in LAW_TYPE_ALIASES.items():
if alias in raw_normalized:
if code not in normalized_values:
normalized_values.append(code)
break
return normalized_values or None
def extract_message_content(completion, purpose: str) -> str:
choices = getattr(completion, "choices", None)
if not choices:
logger.error(
"LLM %s returned empty choices: model=%s id=%s usage=%s raw=%s",
purpose,
getattr(completion, "model", None),
getattr(completion, "id", None),
getattr(completion, "usage", None),
completion,
)
raise RuntimeError(
"LLM provider returned an empty response. Check OPENROUTER model name and provider response."
)
first_choice = choices[0]
message = getattr(first_choice, "message", None)
if message is None:
logger.error(
"LLM %s returned choice without message: model=%s id=%s choice=%s",
purpose,
getattr(completion, "model", None),
getattr(completion, "id", None),
first_choice,
)
raise RuntimeError("LLM provider returned a malformed response without message.")
content = getattr(message, "content", None)
if content is None:
logger.error(
"LLM %s returned empty message content: model=%s id=%s finish_reason=%s message=%s",
purpose,
getattr(completion, "model", None),
getattr(completion, "id", None),
getattr(first_choice, "finish_reason", None),
message,
)
raise RuntimeError("LLM provider returned an empty message content.")
return content
def build_fallback_title(question: str, limit: int = 70) -> str:
title = " ".join(question.strip().split())
if not title:
return "Юридическая консультация"
title = title.rstrip(" .,!?:;")
if len(title) <= limit:
return title
trimmed = title[: limit - 1].rstrip(" .,!?:;")
return f"{trimmed}"
def infer_primary_law_type(category: str | None, question: str) -> str:
inferred = infer_law_types(category)
if inferred:
return inferred[0]
normalized_question = question.lower()
for key, law_types in CATEGORY_MAP.items():
if key in normalized_question:
return law_types[0]
return "other"
def sanitize_answer_text(answer: str) -> str:
sanitized = answer.strip()
replacements = (
(r"(?i)\bSOURCES\b", "нормах закона"),
(r"(?i)\bsource\b", "нормах закона"),
(r"(?i)\bchunk(?:s)?\b", "нормах закона"),
(r"(?i)\bretrieval\b", "поиске норм"),
(
r"(?i)в ваших нормах закона",
"в найденных нормах закона",
),
(
r"(?i)на основании этих источников",
"по найденным нормам закона",
),
(
r"(?i)по этим источникам",
"по найденным нормам закона",
),
(
r"(?i)в базе нет",
"прямого ответа в найденных нормах нет",
),
(
r"(?i)в контексте нет",
"в найденных нормах прямо не указано",
),
)
for pattern, replacement in replacements:
sanitized = re.sub(pattern, replacement, sanitized)
sanitized = re.sub(r"\s{2,}", " ", sanitized)
return sanitized.strip()
def format_numbered_lines(items: list[str]) -> str:
normalized = [" ".join(item.strip().split()) for item in items if item and item.strip()]
return "\n".join(f"{index}. {item}" for index, item in enumerate(normalized, start=1))
def build_sources_section(sources: list[dict]) -> list[str]:
lines: list[str] = []
seen: set[tuple[str, str, str]] = set()
for source in sources:
title = str(source.get("source_title") or "").strip()
article_number = str(source.get("article_number") or "").strip()
article_title = str(source.get("article_title") or "").strip()
key = (title, article_number, article_title)
if not title or key in seen:
continue
seen.add(key)
if article_number and article_title:
lines.append(f"{title}, ст. {article_number}{article_title}")
elif article_number:
lines.append(f"{title}, ст. {article_number}")
else:
lines.append(title)
if len(lines) >= 5:
break
return lines
def render_structured_initial_answer(
payload: StructuredInitialAnswer,
sources: list[dict],
) -> str:
legal_points = payload.legal_points or ["В найденных нормах прямой ответ на вопрос не раскрыт."]
action_steps = payload.action_steps or ["Уточните обстоятельства и проверьте формулировку вопроса."]
risks = payload.risks or ["Ответ зависит от деталей ситуации и содержания применимых норм."]
source_lines = build_sources_section(sources)
if not source_lines:
source_lines = ["Подходящие нормы закона по этому вопросу автоматически не выделились."]
parts = [
"⚖️ Краткий вывод",
payload.short_conclusion.strip(),
"",
"📌 Что говорит закон",
format_numbered_lines(legal_points),
"",
"✅ Что можно сделать",
format_numbered_lines(action_steps),
"",
"⚠️ Риски и ограничения",
format_numbered_lines(risks),
"",
"📚 Найденные источники",
format_numbered_lines(source_lines),
"",
"❗ Важно",
"Ответ носит информационный характер и не заменяет консультацию юриста.",
]
return "\n".join(parts).strip()
def first_sentence(text: str, limit: int = 220) -> str:
normalized = " ".join(text.split())
normalized = re.sub(r"^\d+\s*\.\s*", "", normalized)
normalized = re.sub(r"\s+([,.;:!?])", r"\1", normalized)
if not normalized:
return ""
match = re.split(r"(?<=[.!?])\s+", normalized, maxsplit=1)
sentence = match[0].strip()
if len(sentence) <= limit:
return sentence
trimmed = sentence[: limit - 1].rstrip(" ,;:")
return f"{trimmed}"
def build_structured_answer_fallback(
*,
question: str,
category: str | None,
sources: list[dict],
) -> StructuredInitialAnswer:
legal_points: list[str] = []
for source in sources[:3]:
article_number = str(source.get("article_number") or "").strip()
article_title = str(source.get("article_title") or "").strip()
chunk_text = str(source.get("chunk_text") or "").strip()
summary = first_sentence(chunk_text)
if article_number and article_title and summary:
legal_points.append(f"Статья {article_number} {article_title}: {summary}")
elif article_number and article_title:
legal_points.append(f"Статья {article_number} {article_title}.")
elif summary:
legal_points.append(summary)
if not legal_points:
legal_points.append("В найденных нормах есть общие ориентиры, но прямой ответ зависит от деталей ситуации.")
category_hint = (category or "").lower()
is_criminal = "уголов" in category_hint or any(
str(source.get("law_type") or "") == "criminal" for source in sources
)
if is_criminal:
short_conclusion = (
"По найденным нормам возможна уголовная ответственность, "
"но точная квалификация и последствия зависят от обстоятельств дела."
)
action_steps = [
"Как можно быстрее обратитесь за очной помощью адвоката по уголовным делам.",
"Соберите и сохраните документы, повестки, протоколы и другие материалы, которые у вас уже есть.",
"Подготовьте точную хронологию событий, потому что для оценки важны обстоятельства и формулировка обвинения.",
]
risks = [
"Точная статья и возможное наказание зависят от обстоятельств, мотива, последствий и процессуального статуса.",
"Без изучения материалов дела нельзя надёжно оценить квалификацию и линию защиты.",
]
else:
short_conclusion = (
"По найденным нормам можно дать только общий ориентир; "
"точный вывод зависит от фактических обстоятельств вопроса."
)
action_steps = [
"Уточните ключевые обстоятельства и формулировку вопроса.",
"Соберите документы и доказательства, которые относятся к ситуации.",
"При необходимости получите очную консультацию профильного юриста.",
]
risks = [
"Ответ может измениться, если появятся новые существенные детали.",
"Без полного набора обстоятельств правовая оценка будет предварительной.",
]
return StructuredInitialAnswer(
short_conclusion=short_conclusion,
legal_points=legal_points,
action_steps=action_steps,
risks=risks,
)
def build_classification_fallback(
*,
question: str,
category: str | None,
region: str | None,
) -> ClassificationResult:
primary_law_type = infer_primary_law_type(category, question)
filters = {"law_type": [primary_law_type]} if primary_law_type != "other" else {}
return ClassificationResult(
legal_domain=primary_law_type,
issue_type="general_question",
jurisdiction="RU",
region=region,
needs_clarification=False,
clarification_questions=[],
search_queries=[question],
filters=filters,
)
class LegalAIService:
def __init__(self, client: AsyncOpenAI, llm_model: str) -> None:
self.client = client
self.llm_model = llm_model
async def classify(
self,
question: str,
category: str | None,
region: str | None,
user_type: str | None = None,
history: list[dict[str, str]] | None = None,
) -> ClassificationResult:
logger.info(
"LLM classification started: category=%s region=%s user_type=%s question_length=%s history_items=%s",
category,
region,
user_type,
len(question),
len(history or []),
)
category_hint = category or "не указана"
region_hint = region or "не указан"
user_type_hint = user_type or "не указан"
history_lines = []
for item in (history or [])[-6:]:
role = item.get("role", "user")
content = item.get("content", "")
history_lines.append(f"{role}: {content}")
history_text = "\n".join(history_lines) if history_lines else "нет"
user_prompt = (
f"Категория пользователя: {category_hint}\n"
f"Регион: {region_hint}\n"
f"Тип пользователя: {user_type_hint}\n"
f"История консультации:\n{history_text}\n"
f"Вопрос: {question}\n"
)
try:
completion = await self.client.chat.completions.create(
model=self.llm_model,
temperature=0,
response_format=CLASSIFIER_RESPONSE_FORMAT,
messages=[
{"role": "system", "content": CLASSIFIER_PROMPT},
{"role": "user", "content": user_prompt},
],
)
except Exception as exc:
logger.warning(
"LLM classification request with schema failed, using heuristic fallback: category=%s question=%s error=%s",
category,
question,
exc,
)
return build_classification_fallback(
question=question,
category=category,
region=region,
)
content = extract_message_content(completion, "classification") or "{}"
try:
payload = extract_json(content, "classification")
except RuntimeError:
logger.warning(
"LLM classification schema response was invalid, using heuristic fallback: category=%s question=%s",
category,
question,
)
return build_classification_fallback(
question=question,
category=category,
region=region,
)
search_queries = payload.get("search_queries") or [question]
filters = payload.get("filters") or {}
normalized_law_types = normalize_law_type_values(filters.get("law_type"))
if "law_type" in filters:
if normalized_law_types:
filters["law_type"] = normalized_law_types
else:
filters.pop("law_type", None)
fallback_law_types = infer_law_types(category)
if fallback_law_types and not filters.get("law_type"):
filters["law_type"] = fallback_law_types
result = ClassificationResult(
legal_domain=payload.get("legal_domain", "other"),
issue_type=payload.get("issue_type", "general_question"),
jurisdiction=payload.get("jurisdiction", "RU"),
region=payload.get("region") or region,
needs_clarification=bool(payload.get("needs_clarification", False)),
clarification_questions=payload.get("clarification_questions", []),
search_queries=search_queries,
filters=filters,
)
logger.info(
"LLM classification completed: legal_domain=%s issue_type=%s queries=%s needs_clarification=%s",
result.legal_domain,
result.issue_type,
result.search_queries,
result.needs_clarification,
)
return result
async def answer(
self,
question: str,
category: str | None,
region: str | None,
user_type: str | None,
history: list[dict[str, str]] | None,
sources: list[dict],
) -> str:
logger.info(
"LLM answer generation started: category=%s region=%s user_type=%s sources=%s question_length=%s history_items=%s",
category,
region,
user_type,
len(sources),
len(question),
len(history or []),
)
serialized_sources = json.dumps(sources, ensure_ascii=False, indent=2)
history_lines = []
for item in (history or [])[-6:]:
role = item.get("role", "user")
content = item.get("content", "")
history_lines.append(f"{role}: {content}")
history_text = "\n".join(history_lines) if history_lines else "нет"
has_consultation_history = bool(history)
answer_prompt = FOLLOW_UP_ANSWER_PROMPT if has_consultation_history else ANSWER_PROMPT
user_prompt = (
f"Категория: {category or 'не указана'}\n"
f"Регион: {region or 'не указан'}\n"
f"Тип пользователя: {user_type or 'не указан'}\n"
f"История консультации:\n{history_text}\n"
f"Вопрос пользователя: {question}\n\n"
f"SOURCES:\n{serialized_sources}"
)
try:
if has_consultation_history:
completion = await self.client.chat.completions.create(
model=self.llm_model,
temperature=0.2,
messages=[
{"role": "system", "content": answer_prompt},
{"role": "user", "content": user_prompt},
],
)
else:
completion = await self.client.chat.completions.create(
model=self.llm_model,
temperature=0.2,
response_format=INITIAL_ANSWER_RESPONSE_FORMAT,
messages=[
{"role": "system", "content": answer_prompt},
{"role": "user", "content": user_prompt},
],
)
except Exception as exc:
if has_consultation_history:
raise
logger.warning(
"LLM initial answer request with schema failed, using structured fallback: category=%s question=%s error=%s",
category,
question,
exc,
)
structured_answer = build_structured_answer_fallback(
question=question,
category=category,
sources=sources,
)
answer = render_structured_initial_answer(structured_answer, sources)
logger.info("LLM answer generation completed via fallback: answer_length=%s", len(answer))
return answer
raw_answer = extract_message_content(completion, "answer").strip()
if has_consultation_history:
answer = sanitize_answer_text(raw_answer)
else:
if looks_like_llm_refusal(raw_answer):
logger.warning(
"LLM returned refusal for initial answer, using structured fallback: category=%s question=%s",
category,
question,
)
structured_answer = build_structured_answer_fallback(
question=question,
category=category,
sources=sources,
)
else:
try:
payload = extract_json(raw_answer, "answer")
structured_answer = StructuredInitialAnswer.model_validate(payload)
except (RuntimeError, ValueError) as exc:
logger.warning(
"LLM initial answer schema response was invalid, using structured fallback: category=%s question=%s error=%s",
category,
question,
exc,
)
structured_answer = build_structured_answer_fallback(
question=question,
category=category,
sources=sources,
)
answer = render_structured_initial_answer(structured_answer, sources)
logger.info("LLM answer generation completed: answer_length=%s", len(answer))
return answer
async def generate_consultation_title(
self,
*,
question: str,
category: str | None,
answer: str,
) -> str:
logger.info(
"LLM consultation title generation started: category=%s question_length=%s answer_length=%s",
category,
len(question),
len(answer),
)
user_prompt = (
f"Категория: {category or 'не указана'}\n"
f"Вопрос пользователя: {question}\n"
f"Краткое содержание ответа:\n{answer[:1500]}"
)
completion = await self.client.chat.completions.create(
model=self.llm_model,
temperature=0,
messages=[
{"role": "system", "content": CONSULTATION_TITLE_PROMPT},
{"role": "user", "content": user_prompt},
],
)
content = extract_message_content(completion, "consultation_title")
title = " ".join(content.strip().split()).strip("\"' ")
title = build_fallback_title(title, limit=70)
logger.info("LLM consultation title generation completed: title=%s", title)
return title
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from functools import lru_cache
import logging
from sentence_transformers import SentenceTransformer
from api.config import settings
logger = logging.getLogger(__name__)
class LocalEmbeddingService:
def __init__(self) -> None:
logger.info(
"Loading embedding model: model=%s device=%s",
settings.embedding_model,
settings.embedding_device,
)
self._model = SentenceTransformer(
settings.embedding_model,
device=settings.embedding_device,
)
self._model.max_seq_length = 512
logger.info(
"Embedding model loaded: model=%s max_seq_length=%s",
settings.embedding_model,
self._model.max_seq_length,
)
def encode_documents(self, texts: list[str]) -> list[list[float]]:
logger.info("Encoding document batch: size=%s", len(texts))
return self._model.encode(
texts,
prompt_name="search_document",
normalize_embeddings=True,
convert_to_numpy=True,
batch_size=settings.index_batch_size,
show_progress_bar=False,
).tolist()
def encode_queries(self, texts: list[str]) -> list[list[float]]:
logger.info("Encoding query batch: size=%s", len(texts))
return self._model.encode(
texts,
prompt_name="search_query",
normalize_embeddings=True,
convert_to_numpy=True,
batch_size=settings.index_batch_size,
show_progress_bar=False,
).tolist()
@lru_cache(maxsize=1)
def get_embedding_service() -> LocalEmbeddingService:
return LocalEmbeddingService()
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
import logging
from fastapi.concurrency import run_in_threadpool
from api.clients.chroma_store import ChromaVectorStore
from api.config import settings
from api.schemas import ClassificationResult
from api.services.local_embeddings import LocalEmbeddingService
from shared import ORM
logger = logging.getLogger(__name__)
def normalize_law_types_arg(value: list[str] | str | None) -> list[str] | None:
if value is None:
return None
if isinstance(value, str):
return [value]
normalized = [item for item in value if isinstance(item, str) and item.strip()]
return normalized or None
class HybridRetrievalService:
def __init__(
self,
orm: ORM,
embedder: LocalEmbeddingService,
vector_store: ChromaVectorStore,
) -> None:
self.orm = orm
self.embedder = embedder
self.vector_store = vector_store
async def retrieve(
self,
classification: ClassificationResult,
fallback_law_types: list[str] | None,
top_k: int,
) -> list[dict]:
queries = classification.search_queries or []
law_types = normalize_law_types_arg(
classification.filters.get("law_type")
or fallback_law_types
or None
)
logger.info(
"Hybrid retrieval started: queries=%s law_types=%s jurisdiction=%s top_k=%s",
queries,
law_types,
classification.jurisdiction,
top_k,
)
merged_scores: dict[int, float] = {}
for query in queries:
lexical_hits = await self.orm.search_law_chunks_full_text(
query=query,
law_types=law_types,
jurisdiction=classification.jurisdiction,
limit=settings.fts_top_k,
)
logger.info("Full-text hits for query '%s': %s", query, len(lexical_hits))
for rank, hit in enumerate(lexical_hits):
merged_scores[hit["chunk_id"]] = merged_scores.get(hit["chunk_id"], 0.0) + (
1.2 / (rank + 1)
)
query_embedding = await run_in_threadpool(
self.embedder.encode_queries,
[query],
)
vector_hits = await run_in_threadpool(
self.vector_store.query,
query_embedding,
settings.vector_top_k,
)
ids = vector_hits.get("ids", [[]])[0]
distances = vector_hits.get("distances", [[]])[0]
metadatas = vector_hits.get("metadatas", [[]])[0]
logger.info("Vector hits for query '%s': %s", query, len(ids))
for rank, (chunk_id, distance, metadata) in enumerate(
zip(ids, distances, metadatas)
):
if law_types and metadata.get("law_type") not in law_types:
continue
score = 1.0 / (rank + 1)
score += max(0.0, 1.0 - float(distance or 1.0))
merged_scores[int(chunk_id)] = merged_scores.get(int(chunk_id), 0.0) + score
ranked_ids = [
chunk_id
for chunk_id, _ in sorted(
merged_scores.items(),
key=lambda item: item[1],
reverse=True,
)
][: max(top_k * 3, top_k)]
rows = await self.orm.get_law_chunks_with_sources_by_ids(
ranked_ids,
law_types=law_types,
jurisdiction=classification.jurisdiction,
)
by_id = {row["chunk_id"]: row for row in rows}
results = []
for chunk_id in ranked_ids:
row = by_id.get(chunk_id)
if row is None:
continue
row["score"] = round(merged_scores.get(chunk_id, 0.0), 6)
results.append(row)
if len(results) >= top_k:
break
logger.info("Hybrid retrieval completed: returned=%s", len(results))
return results
+29
View File
@@ -0,0 +1,29 @@
# Telegram bot token from @BotFather
TOKEN=123456789:replace_me
# Optional proxy for Telegram Bot API.
# Leave empty if you connect directly.
# Format: protocol(http/socks5):ip:port:user:pass
TELEGRAM_BOT_PROXY=
# Telegram user id that will have admin access in the bot
BASE_ADMIN=000000000
# URL of the FastAPI service.
# Inside docker compose use the internal service name.
RAG_API_URL=http://api:8080
# Redis connection for FSM/storage.
# Inside docker compose use the internal service name.
REDIS_URL=redis://redisdb:6379/0
# Postgres connection used by the shared DB layer.
# Inside docker compose use the internal service name.
POSTGRES_DB=law_bot_db
POSTGRES_USER=lawbot_user
POSTGRES_PASSWORD=change_me
POSTGRES_HOST=postgredb
POSTGRES_PORT=5432
# All user-facing dates and times are shown in this timezone
TIMEZONE=Europe/Moscow
+16
View File
@@ -0,0 +1,16 @@
# базовый образ Python
FROM python:3.13-alpine
# рабочая директория
WORKDIR /app/bot
ENV PYTHONPATH=/app
# файл зависимостей
COPY bot/requirements.txt /app/
# устанавливаем зависимости
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
+100
View File
@@ -0,0 +1,100 @@
# Aiogram
from aiogram.types.bot_command_scope_all_private_chats import (
BotCommandScopeAllPrivateChats,
)
from aiogram.exceptions import TelegramNetworkError
# Bot
from create_bot import bot, dp, start_command, orm
# Entry
from handlers.start import start_router
from handlers.admin.main import admin_main_router
# Client handlers
from handlers.client.main import client_router
# Admin handlers
from handlers.admin.list_of_users import list_of_users_router
from handlers.admin.statistic import admin_statistic_router
from handlers.admin.management import admin_management_router
from handlers.admin.mailer import admin_mailer_router
from handlers.admin.settings import admin_settings_router
from handlers.admin.blacklist import admin_blacklist_router
# middlewares
from middlewares.users_control import *
from middlewares.album import AlbumMiddleware
# Another
import asyncio
import logging
from decouple import config
from uvloop import run
logger = logging.getLogger(__name__)
async def safe_set_my_commands(retries: int = 3, delay_seconds: int = 5) -> None:
for attempt in range(1, retries + 1):
try:
await bot.set_my_commands(
start_command,
scope=BotCommandScopeAllPrivateChats(),
request_timeout=120,
)
return
except TelegramNetworkError as exc:
if attempt == retries:
logger.warning(
"Could not set bot commands after %s attempts: %s",
retries,
exc,
)
return
logger.warning(
"Telegram API timeout while setting commands, retry %s/%s in %ss",
attempt,
retries,
delay_seconds,
)
await asyncio.sleep(delay_seconds)
async def main():
try:
await orm.init_schema()
await safe_set_my_commands()
await orm.create_admin(int(config("BASE_ADMIN")), "base_admin", "base_admin")
dp.message.middleware(BlacklistMiddleware())
dp.callback_query.middleware(BlacklistMiddleware())
dp.message.middleware(AntiFloodMiddleware())
dp.message.middleware(AlbumMiddleware())
# ENTRY POINTS
dp.include_routers(start_router, admin_main_router)
# CLIENT
dp.include_router(client_router)
# ADMIN
dp.include_routers(
list_of_users_router,
admin_statistic_router,
admin_management_router,
admin_mailer_router,
admin_settings_router,
admin_blacklist_router,
)
# await bot.delete_webhook(drop_pending_updates = True)
await dp.start_polling(bot)
finally:
await bot.session.close()
await orm.close()
if __name__ == "__main__":
run(main())
+85
View File
@@ -0,0 +1,85 @@
# aiogram
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.enums import ParseMode
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder, StorageKey
from aiogram.types import BotCommand
# cfg
from decouple import config
# db
from shared import ORM
# another
import logging, pytz
from urllib.parse import quote
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
TELEGRAM_API_TIMEOUT = 120.0
def build_telegram_proxy_url(proxy_value: str | None) -> str | None:
if proxy_value is None:
return None
proxy_value = proxy_value.strip()
if not proxy_value:
return None
parts = proxy_value.split(":")
if len(parts) < 3:
raise ValueError(
"TELEGRAM_BOT_PROXY must be in format protocol(http/socks5):ip:port:user:pass"
)
protocol, host, port, *auth_parts = parts
if not protocol or not host or not port:
raise ValueError(
"TELEGRAM_BOT_PROXY must be in format protocol(http/socks5):ip:port:user:pass"
)
username = auth_parts[0] if len(auth_parts) > 0 else ""
password = ":".join(auth_parts[1:]) if len(auth_parts) > 1 else ""
if username or password:
auth = f"{quote(username, safe='')}:{quote(password, safe='')}@"
else:
auth = ""
return f"{protocol}://{auth}{host}:{port}"
telegram_proxy_url = build_telegram_proxy_url(
config("TELEGRAM_BOT_PROXY", default="")
)
bot_session = (
AiohttpSession(proxy=telegram_proxy_url, timeout=TELEGRAM_API_TIMEOUT)
if telegram_proxy_url
else AiohttpSession(timeout=TELEGRAM_API_TIMEOUT)
)
if telegram_proxy_url:
logger.info(
"Telegram Bot API proxy enabled for host %s",
telegram_proxy_url.rsplit("@", 1)[-1],
)
redis_url = config("REDIS_URL")
bot = Bot(
token=config("TOKEN"),
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
session=bot_session,
)
storage = RedisStorage.from_url(redis_url)
storage.key_builder = DefaultKeyBuilder(with_bot_id=True)
dp = Dispatcher(storage=storage)
start_command = [BotCommand(command="/start", description="🔄 Перезапустить бота")]
tz = pytz.timezone(config("TIMEZONE"))
orm = ORM()
View File
View File
+221
View File
@@ -0,0 +1,221 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
from aiogram.exceptions import TelegramBadRequest
# Const
from create_bot import orm
# Keyboards
from keyboards.admin.main_kbs import *
# States
from states.admin_states import AdminStates, AdminBlacklistStates
# Another
from contextlib import suppress
# Init
admin_blacklist_router = Router()
@admin_blacklist_router.message(
F.text == "🚫 Черный список", StateFilter(AdminStates.main)
)
@admin_blacklist_router.message(F.text == "↩️ Назад", StateFilter(AdminBlacklistStates))
async def cmd_blacklist(message: types.Message, state: FSMContext):
msg_text = "🚫 Выберите действие:"
await message.answer(text=msg_text, reply_markup=get_blacklist_kb())
await state.set_state(AdminBlacklistStates.main)
# *############################
# *# ADD #
# *############################
@admin_blacklist_router.message(
F.text == " Добавить", StateFilter(AdminBlacklistStates.main)
)
async def cmd_blacklist_add(message: types.Message, state: FSMContext):
msg_text = f" Введите User ID:"
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminBlacklistStates.add_blacklist)
@admin_blacklist_router.message(F.text, StateFilter(AdminBlacklistStates.add_blacklist))
async def cmd_blacklist_add_finish(message: types.Message, state: FSMContext):
# validation
if not message.text.isdigit():
await message.answer(
text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb()
)
return
user_id = int(message.text)
if not await orm.is_user_exists(user_id):
await message.answer(
text="⛔️ Пользователь не существует в БД! Повторите попытку:",
reply_markup=get_back_kb(),
)
return
await orm.create_blacklist(user_id=user_id)
await message.answer(text=f"✅ Черный список обновлен!")
await cmd_blacklist(message, state)
# *############################
# *# DEL #
# *############################
@admin_blacklist_router.message(
F.text == " Удалить", StateFilter(AdminBlacklistStates.main)
)
async def cmd_blacklist_delete(message: types.Message, state: FSMContext):
msg_text = " Введите User ID:"
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminBlacklistStates.del_blacklist)
@admin_blacklist_router.message(F.text, StateFilter(AdminBlacklistStates.del_blacklist))
async def cmd_blacklist_delete_finish(message: types.Message, state: FSMContext):
# validation
if not message.text.isdigit():
await message.answer(
text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb()
)
return
user_id = int(message.text)
if not await orm.is_blacklisted(user_id):
await message.answer(
text="⛔️ Пользователь не найден в ЧС! Повторите попытку:",
reply_markup=get_back_kb(),
)
return
await orm.delete_blacklist(user_id=user_id)
await message.answer(text=f"✅ Черный список обновлен!")
await cmd_blacklist(message, state)
# *############################
# *# LIST #
# *############################
@admin_blacklist_router.message(
F.text == "👁 Открыть список", StateFilter(AdminBlacklistStates.main)
)
async def cmd_blacklist_list(message: types.Message, state: FSMContext):
await state.update_data(blacklist_offset=0)
items = await orm.get_all_blacklist()
if not items:
await message.answer(text="💭 Список пуст.")
return
offset = 0
max_offset = len(items) // 10 + (1 if len(items) % 10 != 0 else 0)
msg_text = f"<b>🚫 Черный список {offset + 1}/{max_offset}</b>\n\n"
for item in items[offset * 10 : (offset + 1) * 10]:
msg_text += f"✦ <code>{item}</code>\n"
await message.answer(
text=msg_text,
reply_markup=get_bookList_ikb(
prefix="admin_blacklist",
offset=0,
max_offset=max_offset,
items=[],
element_col=10,
),
)
async def cmd_blacklist_list_query(query: types.CallbackQuery, state: FSMContext):
data = await state.get_data()
offset = data.get("blacklist_offset")
items = await orm.get_all_blacklist()
if not items:
await query.answer(text="💭 Список пуст.")
return
max_offset = len(items) // 10 + (1 if len(items) % 10 != 0 else 0)
if offset < 0:
offset = max_offset - 1
await state.update_data(blacklist_offset=offset)
elif offset >= max_offset:
offset = 0
await state.update_data(blacklist_offset=offset)
msg_text = f"<b>🚫 Черный список {offset + 1}/{max_offset}</b>\n\n"
for item in items[offset * 10 : (offset + 1) * 10]:
msg_text += f"✦ <code>{item}</code>\n"
with suppress(TelegramBadRequest):
await query.message.edit_text(
text=msg_text,
reply_markup=get_bookList_ikb(
prefix="admin_blacklist",
offset=offset,
max_offset=max_offset,
items=[],
element_col=10,
),
)
await query.answer()
@admin_blacklist_router.callback_query(
F.data == "admin_blacklist_next", StateFilter(AdminBlacklistStates.main)
)
@admin_blacklist_router.callback_query(
F.data == "admin_blacklist_prev", StateFilter(AdminBlacklistStates.main)
)
@admin_blacklist_router.callback_query(
F.data == "admin_blacklist_status", StateFilter(AdminBlacklistStates.main)
)
async def cmd_blacklist_list_actions(query: types.CallbackQuery, state: FSMContext):
state_data = await state.get_data()
if query.data.endswith("next"):
await state.update_data(
blacklist_offset=state_data.get("blacklist_offset", 0) + 1
)
elif query.data.endswith("prev"):
await state.update_data(
blacklist_offset=state_data.get("blacklist_offset", 0) - 1
)
await cmd_blacklist_list_query(query, state)
+53
View File
@@ -0,0 +1,53 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
# Const
from create_bot import tz, orm
# States
from states.admin_states import AdminStates
# Another
import shutil, os
from openpyxl import load_workbook
# Init
list_of_users_router = Router()
@list_of_users_router.message(
F.text == "📑 Список пользователей", StateFilter(AdminStates.main)
)
async def cmd_list_of_users(message: types.Message, state: FSMContext):
# copy the table
table_path = shutil.copy(
src="templates/users.xlsx", dst=f"templates/users_list.xlsx"
)
# load table
book = load_workbook(filename=table_path)
sheet = book["users"]
all_clients = await orm.get_all_users()
for row, user in enumerate(all_clients, 2):
sheet.cell(row=row, column=1, value=user.user_id)
sheet.cell(row=row, column=2, value=user.username)
sheet.cell(row=row, column=3, value=user.fullname)
sheet.cell(
row=row,
column=4,
value=user.register_date.astimezone(tz).strftime(r"%d-%m-%y %H:%M %Z"),
)
book.save(table_path)
await message.answer_document(document=types.FSInputFile(table_path))
if os.path.exists(table_path):
os.remove(table_path)
+133
View File
@@ -0,0 +1,133 @@
# Aiogram imports
import logging
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
# Const
from create_bot import bot, orm
# Keyboards
from keyboards.admin.mailer_kbs import *
# Utils
from utils.text_tools import parse_links_to_inline_markup
# States
from states.admin_states import AdminStates, AdminMailerStates
# Funcs
from handlers.admin.main import show_admin_menu
admin_mailer_router = Router()
logger = logging.getLogger(__name__)
@admin_mailer_router.message(F.text == "✉️ Рассылка", StateFilter(AdminStates.main))
@admin_mailer_router.message(F.text == "↩️ Назад", StateFilter(AdminMailerStates))
async def process_mailer_post(message: types.Message, state: FSMContext):
msg_text = "✉️ Отправьте пост одним сообщением:"
await message.answer(text=msg_text, reply_markup=get_back_to_main_kb())
await state.set_state(AdminMailerStates.post)
@admin_mailer_router.message(StateFilter(AdminMailerStates.post))
async def process_mailer_ikb(message: types.Message, state: FSMContext):
await state.update_data(admin_mailer_post=message.message_id)
msg_text = """✉️ Введите кнопки:
<blockquote>Отправьте ссылку(и) в формате:
[Текст кнопки + ссылка]
Пример:
[Переводчик + https://t.me/TransioBot]
Чтобы добавить несколько кнопок в один ряд, пишите ссылки рядом с предыдущими.
Формат:
[Первый текст + первая ссылка][Второй текст + вторая ссылка]
Чтобы добавить несколько кнопок в строчку, пишите новые ссылки с новой строки.
Формат:
[Первый текст + первая ссылка]
[Второй текст + вторая ссылка]</blockquote>"""
await message.answer(
text=msg_text, reply_markup=get_skip_kb(), disable_web_page_preview=True
)
await state.set_state(AdminMailerStates.ikb)
@admin_mailer_router.message(F.text, StateFilter(AdminMailerStates.ikb))
async def process_mailer_preview(message: types.Message, state: FSMContext):
ikb = (
parse_links_to_inline_markup(message.text)
if message.text != "↪️ Пропустить"
else None
)
await state.update_data(admin_mailer_ikb=ikb)
state_data = await state.get_data()
post = state_data.get("admin_mailer_post")
await message.answer(text="✉️ Предпросмотр:", reply_markup=get_mailer_finish_kb())
try:
await bot.copy_message(
chat_id=message.from_user.id,
from_chat_id=message.from_user.id,
message_id=post,
reply_markup=get_mailer_btn_ikb(buttons_preset=ikb),
)
except Exception:
logger.exception("Mailer preview failed")
await message.answer(text="🔴 Ошибка!")
await process_mailer_post(message, state)
return
await state.set_state(AdminMailerStates.preview)
@admin_mailer_router.message(
F.text == "🟢 Начать рассылку", StateFilter(AdminMailerStates.preview)
)
async def process_mailer_finish(message: types.Message, state: FSMContext):
state_data = await state.get_data()
ikb = state_data.get("admin_mailer_ikb")
post = state_data.get("admin_mailer_post")
all_users = await orm.get_all_user_ids()
# info
await message.answer(text="▶️✉️ Рассылка запущена...")
await state.clear()
# back to main menu
await show_admin_menu(message, state)
counter = 0
for user_id in all_users:
try:
await bot.copy_message(
chat_id=user_id,
from_chat_id=message.from_user.id,
message_id=post,
reply_markup=get_mailer_btn_ikb(buttons_preset=ikb),
)
counter += 1
except Exception:
logger.exception("Mailer copy failed for user_id=%s", user_id)
await message.answer(
text=f"✅ Рассылка завершена! Сообщение отправлено {counter}/{len(all_users)}."
)
+67
View File
@@ -0,0 +1,67 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import Command, StateFilter
from aiogram import Router, F
# Const
from create_bot import orm
# Keyboards
from keyboards.admin.main_kbs import *
# States
from states.admin_states import (
AdminStates,
AdminMailerStates,
AdminManagementStates,
AdminSettingsStates,
AdminBlacklistStates,
)
# Funcs
from handlers.start import cmd_start
# Init
admin_main_router = Router()
@admin_main_router.message(Command("admin"), StateFilter("*"))
async def cmd_login_as_admin(message: types.Message, state: FSMContext):
if message.chat.type != "private":
return
is_admin_exists = await orm.is_admin_exists(user_id=message.from_user.id)
if is_admin_exists:
await show_admin_menu(message, state)
else:
await message.answer(text="🤨")
@admin_main_router.message(F.text == "🔚 Выйти", StateFilter(AdminStates.main))
async def cmd_admin_exit(message: types.Message, state: FSMContext):
await message.answer(text="🚪⠀", reply_markup=types.ReplyKeyboardRemove())
await cmd_start(message, state)
@admin_main_router.message(
F.text == "↩️ Вернуться в меню",
StateFilter(
AdminManagementStates.main,
AdminMailerStates.post,
AdminSettingsStates.main,
AdminBlacklistStates.main,
),
)
async def show_admin_menu(message: types.Message, state: FSMContext):
msg_text = "👮‍♂️ Вы находитесь в админ-панели"
await message.answer(text=msg_text, reply_markup=get_main_menu_kb())
await state.set_state(AdminStates.main)
+142
View File
@@ -0,0 +1,142 @@
# Aiogram imports
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
from aiogram.exceptions import TelegramBadRequest
# Const
from create_bot import bot, storage, StorageKey, orm
# Keyboards
from keyboards.admin.main_kbs import *
# States
from states.admin_states import AdminStates, AdminManagementStates
# Config
from decouple import config
# Another
from contextlib import suppress
# Init
admin_management_router = Router()
@admin_management_router.message(
F.text == "👮‍♂️ Управление админами", StateFilter(AdminStates.main)
)
@admin_management_router.message(
F.text == "↩️ Назад", StateFilter(AdminManagementStates)
)
async def cmd_management(message: types.Message, state: FSMContext):
admins = await orm.get_all_admins()
msg_text = "<i>👮‍♂️ Действующие администраторы</i>\n"
for admin in admins:
msg_text += f"✦ [<code>{admin.user_id}</code>]: {admin.username if admin.username else admin.fullname}\n"
msg_text += f"\n<b>🔽 Выберите действие:</b>"
await message.answer(text=msg_text, reply_markup=get_add_admins_kb())
await state.set_state(AdminManagementStates.main)
# *############################
# *# ADD #
# *############################
@admin_management_router.message(
F.text == " Добавить", StateFilter(AdminManagementStates.main)
)
async def cmd_management_add_id(message: types.Message, state: FSMContext):
msg_text = "➕ Введите User ID нового админа:"
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminManagementStates.add_admin)
@admin_management_router.message(F.text, StateFilter(AdminManagementStates.add_admin))
async def cmd_management_add_finish(message: types.Message, state: FSMContext):
# validation
if not message.text.isdigit():
await message.answer(
text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb()
)
return
user_id = int(message.text)
if not await orm.is_user_exists(user_id):
await message.answer(
text="⛔️ Пользователь не существует в БД! Повторите попытку:",
reply_markup=get_back_kb(),
)
return
user = await orm.get_user(user_id)
await orm.create_admin(user.user_id, user.username, user.fullname)
await message.answer("✅ Успешно!")
await cmd_management(message, state)
# *############################
# *# DELETE #
# *############################
@admin_management_router.message(
F.text == " Удалить", StateFilter(AdminManagementStates.main)
)
async def cmd_management_delete(message: types.Message, state: FSMContext):
msg_text = "➖ Введите ID админа для удаления:"
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminManagementStates.del_admin)
@admin_management_router.message(F.text, StateFilter(AdminManagementStates.del_admin))
async def cmd_management_delete_finish(message: types.Message, state: FSMContext):
# validation
if not message.text.isdigit():
await message.answer(text="⛔️ Только цифры! Повторите попытку:")
return
user_id = int(message.text)
if user_id == int(config("BASE_ADMIN")):
await message.answer(
text="⛔️ Отказано! Повторите попытку:", reply_markup=get_back_kb()
)
return
if not await orm.is_admin_exists(user_id):
await message.answer(text="⛔️ Админ не найден! Повторите попытку:")
return
# change admin state
with suppress(TelegramBadRequest):
await bot.send_message(
chat_id=user_id,
text="☹️ Вы больше не являетесь админом!",
reply_markup=types.ReplyKeyboardRemove(),
)
await storage.set_state(
key=StorageKey(bot_id=bot.id, chat_id=user_id, user_id=user_id), state=None
)
await orm.delete_admin(user_id)
await message.answer("✅ Успешно!")
await cmd_management(message, state)
+85
View File
@@ -0,0 +1,85 @@
# Aiogram imports
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
# Const
from create_bot import orm
# Keyboards
from keyboards.admin.main_kbs import *
# States
from states.admin_states import AdminStates, AdminSettingsStates
# Init
admin_settings_router = Router()
@admin_settings_router.message(F.text == "↩️ Назад", StateFilter(AdminSettingsStates))
@admin_settings_router.message(F.text == "⚙️ Настройки", StateFilter(AdminStates.main))
async def cmd_settings(message: types.Message, state: FSMContext):
msg_text = "⚙️ Выберите, что хотите изменить:"
await message.answer(text=msg_text, reply_markup=get_settings_kb())
await state.set_state(AdminSettingsStates.main)
# *############################
# *# EDIT PHOTO #
# *############################
@admin_settings_router.message(
F.text.in_({"🖼 ..."}), StateFilter(AdminSettingsStates.main)
)
async def cmd_edit_photo(message: types.Message, state: FSMContext):
x = {"🖼 ...": "..."}
setting_key = x.get(message.text)
await state.update_data(setting_key=setting_key)
photo = await orm.get_setting_value(setting_key)
msg_text = f"""<b>Текущее значение:</b>
<blockquote>{photo}</blockquote>
⌨️ Отправьте фото для изменения:"""
if photo:
await message.answer_photo(
photo=photo, caption=msg_text, reply_markup=get_back_kb()
)
else:
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminSettingsStates.edit_photo)
@admin_settings_router.message(F.photo, StateFilter(AdminSettingsStates.edit_photo))
async def cmd_edit_photo_setup(message: types.Message, state: FSMContext):
photo = message.photo[-1].file_id
state_data = await state.get_data()
setting_key = state_data.get("setting_key")
await orm.update_setting_value(setting_key, photo)
msg_text = f"""<b>Текущее значение:</b>
<blockquote>{photo}</blockquote>
⌨️ Отправьте фото для изменения:"""
if photo:
await message.answer_photo(
photo=photo, caption=msg_text, reply_markup=get_back_kb()
)
else:
await message.answer(text=msg_text, reply_markup=get_back_kb())
await state.set_state(AdminSettingsStates.edit_photo)
+28
View File
@@ -0,0 +1,28 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import StateFilter
from aiogram import Router, F
# Const
from create_bot import orm
# States
from states.admin_states import AdminStates
# Init
admin_statistic_router = Router()
@admin_statistic_router.message(
F.text == "📊 Статистика", StateFilter(AdminStates.main)
)
async def cmd_statistic(message: types.Message, state: FSMContext):
users_count = await orm.get_users_count()
msg_text = f"""<i>📊 Статистика</i>
🔹 Кол-во пользователей в боте: {users_count:,} чел."""
await message.answer(text=msg_text)
View File
+506
View File
@@ -0,0 +1,506 @@
import aiogram.types as types
from aiogram import F, Router
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from create_bot import orm, tz
from keyboards.reply_keyboards import (
CATEGORY_BY_TEXT,
USER_TYPE_BY_TEXT,
USER_TYPE_LABEL_BY_CODE,
get_back_to_menu_kb,
get_categories_kb,
get_consultation_actions_ikb,
get_consultations_ikb,
get_delete_profile_kb,
get_main_menu_kb,
get_profile_kb,
get_user_types_kb,
)
from states.client_states import AskQuestionStates, MainStates, ProfileStates
from utils.rag_api import ask_rag_answer
from utils.text_tools import format_llm_answer_html, split_plain_text_chunks, to_html
from datetime import datetime, timezone
client_router = Router()
DAILY_CONSULTATION_LIMIT = 5
CONSULTATION_MESSAGE_LIMIT = 5
def get_today_start_utc() -> datetime:
now_local = datetime.now(tz)
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
return start_local.astimezone(timezone.utc)
def build_start_text() -> str:
return (
"Здравствуйте. Я юридический ИИ-консультант по законам РФ.\n\n"
"Я могу помочь:\n"
"— разобраться в ситуации;\n"
"— найти применимые нормы закона;\n"
"— объяснить их простыми словами;\n"
"— дать базовый план действий.\n\n"
"Ответ носит информационный характер и не заменяет консультацию юриста."
)
def build_help_text() -> str:
return (
"ℹ️ Как пользоваться ботом\n\n"
"1. Нажмите «Задать вопрос».\n"
"2. Выберите категорию.\n"
"3. Опишите ситуацию.\n"
"4. Укажите регион РФ.\n"
"5. Получите ответ с найденными источниками.\n\n"
"Пример хорошего вопроса:\n"
"«Работодатель не выплатил зарплату за апрель. Работаю официально, Москва. Что делать?»\n\n"
"Бот не заменяет юриста и не гарантирует результат спора."
)
def build_profile_text(user) -> str:
return (
"👤 Профиль\n\n"
f"Страна: {to_html(user.country or 'Россия')}\n"
f"Регион: {to_html(user.region or 'не указан')}\n"
f"Тип пользователя: {to_html(USER_TYPE_LABEL_BY_CODE.get(user.user_type, 'Физлицо'))}"
)
def build_consultations_text(consultations: list) -> str:
lines = ["📚 Ваши консультации:\n"]
for index, consultation in enumerate(consultations, start=1):
title = consultation.title or consultation.category
lines.append(
f"{index}. {to_html(title)}{consultation.updated_at.astimezone(tz).strftime('%d.%m.%Y')}"
)
return "\n".join(lines)
def build_consultation_text(consultation, messages: list) -> str:
lines = [
f"📚 Консультация #{consultation.id}",
"",
f"Категория: {to_html(consultation.category)}",
f"Регион: {to_html(consultation.region or 'не указан')}",
f"Статус: {to_html(consultation.status)}",
"",
]
if not messages:
lines.append("Сообщений пока нет.")
return "\n".join(lines)
for message in messages[-6:]:
if message.role == "user":
title = "👤 Вопрос"
elif message.role == "assistant":
title = "🤖 Ответ"
else:
title = "️ Сообщение"
lines.append(title)
if message.role == "assistant":
lines.append(format_llm_answer_html(message.content[:1800]))
else:
lines.append(to_html(message.content[:1800]))
lines.append("")
return "\n".join(lines).strip()
def build_fallback_error_text(error_text: str) -> str:
if "No reliable law chunks" in error_text:
return (
"Я не нашел в базе надежную норму по этому вопросу. "
"Попробуйте уточнить ситуацию, добавить детали или выбрать другую категорию."
)
return error_text
async def show_main_menu(message: types.Message, state: FSMContext) -> None:
await message.answer(build_start_text(), reply_markup=get_main_menu_kb())
await state.set_state(MainStates.main)
async def process_question(
message: types.Message,
state: FSMContext,
*,
category: str,
question: str,
region: str,
consultation_id: int | None = None,
) -> None:
user = await orm.get_user(message.from_user.id)
if user is None:
await show_main_menu(message, state)
return
if consultation_id is None:
consultations_today = await orm.count_user_consultations_since(
user_id=message.from_user.id,
since=get_today_start_utc(),
)
if consultations_today >= DAILY_CONSULTATION_LIMIT:
await message.answer(
"На сегодня достигнут лимит: 5 новых консультаций. Попробуйте завтра.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
else:
message_count = await orm.count_user_messages_in_consultation(consultation_id)
if message_count >= CONSULTATION_MESSAGE_LIMIT:
await message.answer(
"В этой консультации уже достигнут лимит: 5 сообщений пользователя. "
"Создайте новую консультацию.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
history = []
if consultation_id is not None:
history = await orm.get_consultation_messages(consultation_id)
await state.set_state(AskQuestionStates.processing)
await message.answer("⏳ Ищу подходящие нормы закона и готовлю ответ...")
try:
response = await ask_rag_answer(
user_id=message.from_user.id,
consultation_id=consultation_id,
question=question,
category=category,
region=region,
user_type=user.user_type,
history=history,
)
except RuntimeError as exc:
await message.answer(
build_fallback_error_text(str(exc)),
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
if user.region != region:
await orm.set_user_region(message.from_user.id, region)
raw_answer_text = response.get("answer", "Не удалось сформировать ответ.")
answer_chunks = [
format_llm_answer_html(chunk)
for chunk in split_plain_text_chunks(raw_answer_text)
]
saved_consultation_id = response.get("consultation_id")
for index, answer_chunk in enumerate(answer_chunks):
reply_markup = None
if index == len(answer_chunks) - 1 and saved_consultation_id is not None:
reply_markup = get_consultation_actions_ikb(saved_consultation_id)
await message.answer(answer_chunk, reply_markup=reply_markup)
await message.answer(
"Можете задать новый вопрос или открыть историю консультаций.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
@client_router.message(F.text == "↩️ В меню", StateFilter("*"))
async def return_to_main_menu(message: types.Message, state: FSMContext):
await show_main_menu(message, state)
@client_router.message(F.text == "️ Помощь", StateFilter("*"))
async def show_help(message: types.Message, state: FSMContext):
await message.answer(build_help_text(), reply_markup=get_main_menu_kb())
await state.set_state(MainStates.main)
@client_router.message(F.text == "⚖️ Задать вопрос", StateFilter("*"))
async def ask_question_entry(message: types.Message, state: FSMContext):
await message.answer(
"Выберите категорию вопроса:",
reply_markup=get_categories_kb(),
)
await state.set_state(AskQuestionStates.choosing_category)
@client_router.message(
F.text.in_(list(CATEGORY_BY_TEXT.keys())),
AskQuestionStates.choosing_category,
)
async def set_question_category(message: types.Message, state: FSMContext):
await state.update_data(
category_text=message.text,
)
await message.answer(
"Опишите ситуацию одним сообщением.\n\n"
"Постарайтесь указать:\n"
"— что произошло;\n"
"— когда произошло;\n"
"с кем спор;\n"
"— чего вы хотите добиться.",
reply_markup=get_back_to_menu_kb(),
)
await state.set_state(AskQuestionStates.waiting_question)
@client_router.message(AskQuestionStates.waiting_question)
async def set_question_text(message: types.Message, state: FSMContext):
question = (message.text or "").strip()
if not question:
await message.answer("Опишите ситуацию текстом одним сообщением.")
return
user = await orm.get_user(message.from_user.id)
await state.update_data(question=question)
if user and user.region:
await message.answer(
"Укажите регион РФ, где произошла ситуация.\n\n"
f"Сейчас в профиле указан регион: {to_html(user.region)}\n"
"Если он подходит, отправьте символ: -",
reply_markup=get_back_to_menu_kb(),
)
else:
await message.answer(
"Укажите регион РФ, где произошла ситуация.\n\n"
"Например:\n"
"Москва\n"
"Санкт-Петербург\n"
"Краснодарский край\n"
"Республика Татарстан",
reply_markup=get_back_to_menu_kb(),
)
await state.set_state(AskQuestionStates.waiting_region)
@client_router.message(AskQuestionStates.waiting_region)
async def set_region_and_process(message: types.Message, state: FSMContext):
payload = await state.get_data()
user = await orm.get_user(message.from_user.id)
region = (message.text or "").strip()
if region == "-" and user and user.region:
region = user.region
if not region:
await message.answer("Укажите регион РФ текстом.")
return
await process_question(
message,
state,
category=payload["category_text"],
question=payload["question"],
region=region,
)
@client_router.message(F.text == "📚 Мои консультации", StateFilter("*"))
async def show_consultations(message: types.Message, state: FSMContext):
consultations = await orm.list_user_consultations(message.from_user.id)
if not consultations:
await message.answer(
"У вас пока нет консультаций.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
await message.answer(
build_consultations_text(consultations),
reply_markup=get_consultations_ikb(consultations),
)
await state.set_state(MainStates.main)
@client_router.callback_query(F.data.startswith("consultation:open:"))
async def open_consultation(callback: types.CallbackQuery, state: FSMContext):
consultation_id = int(callback.data.rsplit(":", 1)[-1])
consultation = await orm.get_consultation(consultation_id, callback.from_user.id)
if consultation is None:
await callback.answer("Консультация не найдена.", show_alert=True)
return
messages = await orm.get_consultation_messages(consultation_id)
await callback.message.answer(
build_consultation_text(consultation, messages),
reply_markup=get_consultation_actions_ikb(consultation_id),
)
await callback.answer()
await state.set_state(MainStates.main)
@client_router.callback_query(F.data.startswith("consultation:continue:"))
async def continue_consultation(callback: types.CallbackQuery, state: FSMContext):
consultation_id = int(callback.data.rsplit(":", 1)[-1])
consultation = await orm.get_consultation(consultation_id, callback.from_user.id)
if consultation is None:
await callback.answer("Консультация не найдена.", show_alert=True)
return
await state.update_data(
consultation_id=consultation.id,
category_text=consultation.category,
region=consultation.region,
)
await callback.message.answer(
"Напишите следующий вопрос по этой консультации одним сообщением.",
reply_markup=get_back_to_menu_kb(),
)
await callback.answer()
await state.set_state(AskQuestionStates.waiting_continue_question)
@client_router.callback_query(F.data.startswith("consultation:delete:"))
async def delete_consultation(callback: types.CallbackQuery, state: FSMContext):
consultation_id = int(callback.data.rsplit(":", 1)[-1])
await orm.delete_consultation(consultation_id, callback.from_user.id)
consultations = await orm.list_user_consultations(callback.from_user.id)
await callback.answer("Консультация удалена.")
if not consultations:
await callback.message.answer(
"Консультаций больше не осталось.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
await callback.message.answer(
build_consultations_text(consultations),
reply_markup=get_consultations_ikb(consultations),
)
await state.set_state(MainStates.main)
@client_router.message(AskQuestionStates.waiting_continue_question)
async def process_continue_question(message: types.Message, state: FSMContext):
question = (message.text or "").strip()
if not question:
await message.answer("Напишите вопрос текстом одним сообщением.")
return
payload = await state.get_data()
consultation_id = payload.get("consultation_id")
consultation = await orm.get_consultation(consultation_id, message.from_user.id)
if consultation is None:
await message.answer(
"Консультация не найдена. Начните новую.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
return
await process_question(
message,
state,
category=consultation.category,
question=question,
region=consultation.region or payload.get("region"),
consultation_id=consultation.id,
)
@client_router.message(F.text == "👤 Профиль", StateFilter("*"))
async def show_profile(message: types.Message, state: FSMContext):
user = await orm.get_user(message.from_user.id)
if user is None:
await show_main_menu(message, state)
return
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
await state.set_state(MainStates.main)
@client_router.message(F.text == "🌍 Изменить регион", StateFilter("*"))
async def change_region_start(message: types.Message, state: FSMContext):
await message.answer(
"Напишите новый регион РФ одним сообщением.",
reply_markup=get_back_to_menu_kb(),
)
await state.set_state(ProfileStates.waiting_region)
@client_router.message(ProfileStates.waiting_region)
async def change_region_finish(message: types.Message, state: FSMContext):
region = (message.text or "").strip()
if not region:
await message.answer("Регион не должен быть пустым.")
return
await orm.set_user_region(message.from_user.id, region)
user = await orm.get_user(message.from_user.id)
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
await state.set_state(MainStates.main)
@client_router.message(F.text == "👔 Изменить тип пользователя", StateFilter("*"))
async def change_user_type_start(message: types.Message, state: FSMContext):
await message.answer(
"Выберите тип пользователя:",
reply_markup=get_user_types_kb(),
)
await state.set_state(ProfileStates.waiting_user_type)
@client_router.message(
F.text.in_(list(USER_TYPE_BY_TEXT.keys())),
ProfileStates.waiting_user_type,
)
async def change_user_type_finish(message: types.Message, state: FSMContext):
await orm.set_user_type(message.from_user.id, USER_TYPE_BY_TEXT[message.text])
user = await orm.get_user(message.from_user.id)
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
await state.set_state(MainStates.main)
@client_router.message(F.text == "🗑 Удалить мои данные", StateFilter("*"))
async def delete_profile_start(message: types.Message, state: FSMContext):
await message.answer(
"Удалить историю консультаций и сбросить профиль?",
reply_markup=get_delete_profile_kb(),
)
await state.set_state(ProfileStates.confirm_delete)
@client_router.message(F.text == "✅ Да, удалить", ProfileStates.confirm_delete)
async def delete_profile_finish(message: types.Message, state: FSMContext):
await orm.delete_user(message.from_user.id)
await orm.create_user(
user_id=message.from_user.id,
username="@" + message.from_user.username if message.from_user.username else None,
fullname=to_html(message.from_user.full_name),
register_date=datetime.now(timezone.utc),
)
await message.answer(
"Данные удалены. История очищена, профиль сброшен.",
reply_markup=get_main_menu_kb(),
)
await state.set_state(MainStates.main)
@client_router.message(MainStates.main)
async def main_menu_fallback(message: types.Message):
await message.answer(
"Выберите действие в меню ниже.",
reply_markup=get_main_menu_kb(),
)
+59
View File
@@ -0,0 +1,59 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import CommandStart, StateFilter
from aiogram import Router, F
# Utils
from utils.text_tools import to_html
# Const
from create_bot import orm
# Keyboards
from keyboards.reply_keyboards import get_main_menu_kb
# States
from states.client_states import MainStates
# Another
from datetime import datetime, timezone
# Init
start_router = Router()
@start_router.message(CommandStart(), StateFilter("*"))
async def cmd_start(message: types.Message, state: FSMContext):
if message.chat.type != "private":
return
user_id = message.from_user.id
username = (
"@" + message.from_user.username
if message.from_user.username is not None
else None
)
fullname = to_html(message.from_user.full_name)
await orm.create_user(
user_id=user_id,
username=username,
fullname=fullname,
register_date=datetime.now(timezone.utc),
)
msg_text = (
"Здравствуйте. Я юридический ИИ-консультант по законам РФ.\n\n"
"Я могу помочь:\n"
"— разобраться в ситуации;\n"
"— найти применимые нормы закона;\n"
"— объяснить их простыми словами;\n"
"— дать базовый план действий.\n\n"
"Ответ носит информационный характер и не заменяет консультацию юриста."
)
await message.answer(text=msg_text, reply_markup=get_main_menu_kb())
await state.set_state(MainStates.main)
View File
+55
View File
@@ -0,0 +1,55 @@
# Aiogram imports
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
from aiogram.types import InlineKeyboardButton, KeyboardButton
def get_back_to_main_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="↩️ Вернуться в меню"))
return builder.as_markup(resize_keyboard=True)
def get_back_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="↩️ Назад"))
return builder.as_markup(resize_keyboard=True)
def get_skip_kb():
builder = ReplyKeyboardBuilder()
builder.add(KeyboardButton(text="↪️ Пропустить"), KeyboardButton(text="↩️ Назад"))
builder.adjust(1)
return builder.as_markup(resize_keyboard=True)
def get_mailer_finish_kb():
builder = ReplyKeyboardBuilder()
builder.add(
KeyboardButton(text="🟢 Начать рассылку"), KeyboardButton(text="↩️ Назад")
)
builder.adjust(1)
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_mailer_btn_ikb(buttons_preset: list[str] | None):
builder = InlineKeyboardBuilder()
if buttons_preset:
for row in buttons_preset:
for btn_name, btn_url in row:
builder.row(InlineKeyboardButton(text=btn_name, url=btn_url))
return builder.as_markup()
+95
View File
@@ -0,0 +1,95 @@
# Aiogram imports
from aiogram.utils.keyboard import (
ReplyKeyboardBuilder,
KeyboardButton,
InlineKeyboardBuilder,
)
from aiogram.types import (
ReplyKeyboardMarkup,
InlineKeyboardMarkup,
InlineKeyboardButton,
)
def get_main_menu_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="✉️ Рассылка"))
builder.row(
KeyboardButton(text="🚫 Черный список"), KeyboardButton(text="⚙️ Настройки")
)
builder.row(
KeyboardButton(text="📑 Список пользователей"),
KeyboardButton(text="👮‍♂️ Управление админами"),
)
builder.row(KeyboardButton(text="🔚 Выйти"))
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_add_admins_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text=" Добавить"), KeyboardButton(text=" Удалить"))
builder.row(KeyboardButton(text="↩️ Вернуться в меню"))
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_back_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="↩️ Назад"))
return builder.as_markup(resize_keyboard=True)
def get_settings_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.add(KeyboardButton(text="↩️ Вернуться в меню"))
builder.adjust(2)
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_blacklist_kb():
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="👁 Открыть список"))
builder.row(KeyboardButton(text=" Добавить"), KeyboardButton(text=" Удалить"))
builder.row(KeyboardButton(text="↩️ Вернуться в меню"))
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_bookList_ikb(
prefix: str, offset: int, max_offset: int, items: list[tuple], element_col: int = 10
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for item_id, item_name in items[offset * element_col : (offset + 1) * element_col]:
builder.row(
InlineKeyboardButton(
text=f"{item_name}", callback_data=f"{prefix}_pick_{item_id}"
)
)
builder.row(
InlineKeyboardButton(text="⬅️", callback_data=f"{prefix}_prev"),
InlineKeyboardButton(text="➡️", callback_data=f"{prefix}_next"),
)
return builder.as_markup()
+3
View File
@@ -0,0 +1,3 @@
# Aiogram imports
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+132
View File
@@ -0,0 +1,132 @@
# Aiogram imports
from aiogram.types import InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from decouple import config
import pytz
QUESTION_CATEGORIES = [
("💼 Работа", "labor"),
("🛒 Защита прав потребителей", "consumer"),
("🏠 Жилье / аренда", "housing"),
("👪 Семья", "family"),
("💰 Долги / займы", "civil"),
("📄 Договоры", "civil"),
("⚖️ Суд / процесс", "procedural"),
("❓ Другое", "other"),
]
USER_TYPES = [
("Физлицо", "physical_person"),
("ИП", "individual_entrepreneur"),
("ООО", "company"),
]
CATEGORY_BY_TEXT = {text: code for text, code in QUESTION_CATEGORIES}
USER_TYPE_BY_TEXT = {text: code for text, code in USER_TYPES}
USER_TYPE_LABEL_BY_CODE = {code: text for text, code in USER_TYPES}
TZ = pytz.timezone(config("TIMEZONE"))
def get_main_menu_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="⚖️ Задать вопрос"))
builder.row(
KeyboardButton(text="📚 Мои консультации"),
KeyboardButton(text="👤 Профиль"),
)
builder.row(KeyboardButton(text="️ Помощь"))
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_categories_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
for text, _ in QUESTION_CATEGORIES:
builder.add(KeyboardButton(text=text))
builder.add(KeyboardButton(text="↩️ В меню"))
builder.adjust(1)
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_back_to_menu_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="↩️ В меню"))
return builder.as_markup(resize_keyboard=True)
def get_profile_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="🌍 Изменить регион"))
builder.row(KeyboardButton(text="👔 Изменить тип пользователя"))
builder.row(KeyboardButton(text="🗑 Удалить мои данные"))
builder.row(KeyboardButton(text="↩️ В меню"))
return builder.as_markup(resize_keyboard=True, is_persistent=True)
def get_user_types_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
for text, _ in USER_TYPES:
builder.add(KeyboardButton(text=text))
builder.add(KeyboardButton(text="↩️ В меню"))
builder.adjust(1)
return builder.as_markup(resize_keyboard=True)
def get_delete_profile_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
builder.row(KeyboardButton(text="✅ Да, удалить"))
builder.row(KeyboardButton(text="↩️ В меню"))
return builder.as_markup(resize_keyboard=True)
def get_consultations_ikb(consultations: list) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for consultation in consultations:
title = consultation.title or consultation.category
updated_at = consultation.updated_at.astimezone(TZ).strftime("%d.%m.%Y")
builder.row(
ai_button(
text=f"{title[:48]}{updated_at}",
callback_data=f"consultation:open:{consultation.id}",
)
)
return builder.as_markup()
def get_consultation_actions_ikb(consultation_id: int) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.row(
ai_button(
text="🔁 Продолжить",
callback_data=f"consultation:continue:{consultation_id}",
)
)
builder.row(
ai_button(
text="🗑 Удалить",
callback_data=f"consultation:delete:{consultation_id}",
)
)
return builder.as_markup()
def ai_button(text: str, callback_data: str):
from aiogram.types import InlineKeyboardButton
return InlineKeyboardButton(text=text, callback_data=callback_data)
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from typing import Any, Dict, Union
from aiogram import BaseMiddleware
from aiogram.types import Message
class AlbumMiddleware(BaseMiddleware):
def __init__(self, latency: Union[int, float] = 0.19):
# Initialize latency and album_data dictionary
self.latency = latency
self.album_data = {}
#
def collect_album_messages(self, event: Message):
"""
Collect messages of the same media group.
"""
# # Check if media_group_id exists in album_data
if event.media_group_id not in self.album_data:
# # Create a new entry for the media group
self.album_data[event.media_group_id] = {"messages": []}
#
# # Append the new message to the media group
self.album_data[event.media_group_id]["messages"].append(event)
#
# # Return the total number of messages in the current media group
return len(self.album_data[event.media_group_id]["messages"])
#
async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any:
"""
Main middleware logic.
"""
# # If the event has no media_group_id, pass it to the handler immediately
if not event.media_group_id:
return await handler(event, data)
#
# # Collect messages of the same media group
total_before = self.collect_album_messages(event)
#
# # Wait for a specified latency period
await asyncio.sleep(self.latency)
#
# # Check the total number of messages after the latency
total_after = len(self.album_data[event.media_group_id]["messages"])
#
# # If new messages were added during the latency, exit
if total_before != total_after:
return
#
# # Sort the album messages by message_id and add to data
album_messages = self.album_data[event.media_group_id]["messages"]
album_messages.sort(key=lambda x: x.message_id)
data["album"] = album_messages
#
# # Remove the media group from tracking to free up memory
del self.album_data[event.media_group_id]
# # Call the original event handler
return await handler(event, data)
+93
View File
@@ -0,0 +1,93 @@
from aiogram import types
from aiogram import BaseMiddleware
from datetime import datetime, timedelta, timezone
from collections import deque
import asyncio
# Const
from create_bot import orm
class AntiFloodMiddleware(BaseMiddleware):
def __init__(
self, max_messages: int = 5, interval: float = 2, block_time: float = 60.0
):
"""
Инициализация AntiFloodMiddleware.
:param max_messages: Максимальное количество сообщений.
:param interval: Временной интервал (в секундах) для проверки сообщений.
:param block_time: Время блокировки пользователя (в секундах).
"""
super(AntiFloodMiddleware, self).__init__()
self.max_messages = max_messages
self.interval = interval
self.block_time = block_time
self.user_messages = {} # user_id: deque of message timestamps
self.blocked_users = {} # user_id: unblock_time
self.lock = asyncio.Lock() # Для обеспечения потокобезопасности
async def __call__(self, handler, event: types.Message, data):
user_id = event.from_user.id
current_time = datetime.now(timezone.utc)
async with self.lock:
# Проверка, заблокирован ли пользователь
if user_id in self.blocked_users:
unblock_time = self.blocked_users[user_id]
if current_time < unblock_time:
# Пользователь всё ещё заблокирован
return
else:
# Блокировка истекла
del self.blocked_users[user_id]
if isinstance(event, types.CallbackQuery):
return await handler(event, data)
# Инициализация очереди сообщений для пользователя, если её ещё нет
if user_id not in self.user_messages:
self.user_messages[user_id] = deque()
user_queue = self.user_messages[user_id]
user_queue.append(current_time)
# Удаление сообщений, которые старше интервала
while (
user_queue
and (current_time - user_queue[0]).total_seconds() > self.interval
):
user_queue.popleft()
# Проверка, превысил ли пользователь лимит сообщений
if len(user_queue) > self.max_messages:
# Блокировка пользователя
self.blocked_users[user_id] = current_time + timedelta(
seconds=self.block_time
)
# Очистка очереди сообщений
del self.user_messages[user_id]
await event.answer(text="🧊 Вы заморожены на 1 минуту за флуд!")
# Отмена обработки сообщения и блокировка
return
return await handler(event, data)
class BlacklistMiddleware(BaseMiddleware):
def __init__(self):
super().__init__()
async def __call__(self, handler, event: types.Update, data: dict):
user_id = self.get_user_id(event)
if user_id:
if await orm.is_blacklisted(user_id):
return
return await handler(event, data)
def get_user_id(self, event: types.Update):
return event.from_user.id
+42
View File
@@ -0,0 +1,42 @@
aiofiles==24.1.0
aiogram==3.17.0
aiohappyeyeballs==2.4.6
aiohttp==3.11.12
aiohttp-socks==0.10.1
aiosignal==1.3.2
annotated-types==0.7.0
asyncio==3.4.3
asyncpg==0.30.0
attrs==25.1.0
beautifulsoup4==4.13.4
certifi==2025.1.31
charset-normalizer==3.4.1
dotenv-cli==3.4.1
et_xmlfile==2.0.0
frozenlist==1.5.0
greenlet==3.1.1
httpx==0.28.1
idna==3.10
magic-filter==1.0.12
markdown-it-py==3.0.0
mdurl==0.1.2
multidict==6.1.0
openpyxl==3.1.5
propcache==0.2.1
pydantic==2.10.6
pydantic_core==2.27.2
Pygments==2.19.1
python-decouple==3.8
redis==5.2.1
requests==2.32.3
rich==13.9.4
simplejson==3.20.1
SQLAlchemy==2.0.38
soupsieve==2.7
typing_extensions==4.12.2
urllib3==2.3.0
uvloop==0.21.0
yarl==1.18.3
fastapi
uvicorn
pytz
View File
+36
View File
@@ -0,0 +1,36 @@
# Aiogram imports
from aiogram.fsm.state import State, StatesGroup
class AdminStates(StatesGroup):
main = State()
class AdminMailerStates(StatesGroup):
post = State()
ikb = State()
preview = State()
class AdminManagementStates(StatesGroup):
main = State()
add_admin = State()
del_admin = State()
class AdminSettingsStates(StatesGroup):
main = State()
edit_photo = State()
class AdminBlacklistStates(StatesGroup):
main = State()
add_blacklist = State()
del_blacklist = State()
+20
View File
@@ -0,0 +1,20 @@
# Aiogram imports
from aiogram.fsm.state import State, StatesGroup
class MainStates(StatesGroup):
main = State()
class AskQuestionStates(StatesGroup):
choosing_category = State()
waiting_question = State()
waiting_region = State()
processing = State()
waiting_continue_question = State()
class ProfileStates(StatesGroup):
waiting_region = State()
waiting_user_type = State()
confirm_delete = State()
Binary file not shown.
View File
+17
View File
@@ -0,0 +1,17 @@
import simplejson as json
# init
CFG_PATH = "cfg/config.json"
# load cfg and return it
def load_config(cfg_path=CFG_PATH):
with open(cfg_path, "r", encoding="utf-8") as config_fp:
return json.load(config_fp)
def rewrite_config(obj, cfg_path=CFG_PATH):
with open(cfg_path, "w", encoding="utf-8") as config_fp:
json.dump(obj, config_fp, indent=4)
+85
View File
@@ -0,0 +1,85 @@
import logging
import httpx
from decouple import config
logger = logging.getLogger(__name__)
RAG_API_URL = config("RAG_API_URL", default="http://api:8080").rstrip("/")
HTTP_TIMEOUT = httpx.Timeout(120.0, connect=20.0)
def build_history_payload(messages: list) -> list[dict]:
payload = []
for message in messages[-6:]:
payload.append(
{
"role": message.role,
"content": message.content,
}
)
return payload
async def ask_rag_answer(
*,
user_id: int,
question: str,
category: str | None,
region: str | None,
user_type: str | None,
consultation_id: int | None = None,
history: list | None = None,
top_k: int = 5,
) -> dict:
payload = {
"user_id": user_id,
"consultation_id": consultation_id,
"save_history": True,
"question": question,
"category": category,
"region": region,
"user_type": user_type,
"history": build_history_payload(history or []),
"top_k": top_k,
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
response = await client.post(
f"{RAG_API_URL}/api/v1/rag/answer",
json=payload,
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as exc:
detail = ""
try:
detail = exc.response.json().get("detail", "")
except Exception:
detail = exc.response.text
if "No reliable law chunks" in detail:
detail = (
"Я не нашел в базе надежную норму по этому вопросу. "
"Попробуйте уточнить ситуацию и задать вопрос еще раз."
)
elif "User was not found" in detail:
detail = (
"Профиль пользователя не найден в базе. "
"Нажмите /start и попробуйте еще раз."
)
elif "Consultation was not found" in detail:
detail = (
"Не удалось найти выбранную консультацию. "
"Откройте историю заново или начните новую консультацию."
)
logger.warning("RAG API returned %s: %s", exc.response.status_code, detail)
raise RuntimeError(detail or "Сервис анализа вернул ошибку.")
except httpx.HTTPError as exc:
logger.exception("RAG API request failed")
raise RuntimeError("Не удалось связаться с сервисом анализа. Попробуйте позже.") from exc
+120
View File
@@ -0,0 +1,120 @@
import html
import re
def to_html(obj):
return html.escape(str(obj))
def format_llm_answer_html(text: str | None) -> str:
if text is None:
return ""
escaped = html.escape(str(text).replace("\r\n", "\n").strip())
normalized_lines = []
for line in escaped.split("\n"):
normalized_line = re.sub(r"^\s*[-*]\s+", "", line.rstrip())
normalized_lines.append(normalized_line)
formatted = "\n".join(normalized_lines)
formatted = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", formatted)
return formatted
def split_plain_text_chunks(text: str | None, limit: int = 3500) -> list[str]:
if text is None:
return [""]
normalized = str(text).replace("\r\n", "\n").strip()
if not normalized:
return [""]
paragraphs = normalized.split("\n\n")
chunks: list[str] = []
current = ""
for paragraph in paragraphs:
paragraph = paragraph.strip()
if not paragraph:
continue
candidate = paragraph if not current else f"{current}\n\n{paragraph}"
if len(candidate) <= limit:
current = candidate
continue
if current:
chunks.append(current)
current = ""
if len(paragraph) <= limit:
current = paragraph
continue
lines = paragraph.split("\n")
line_buffer = ""
for line in lines:
line = line.rstrip()
line_candidate = line if not line_buffer else f"{line_buffer}\n{line}"
if len(line_candidate) <= limit:
line_buffer = line_candidate
continue
if line_buffer:
chunks.append(line_buffer)
line_buffer = ""
while len(line) > limit:
chunks.append(line[:limit].rstrip())
line = line[limit:].lstrip()
line_buffer = line
if line_buffer:
current = line_buffer
if current:
chunks.append(current)
return chunks or [normalized[:limit]]
def parse_links_to_inline_markup(message: str) -> list:
"""
Парсит сообщение с форматированными ссылками и возвращает список рядов кнопок.
Формат входного сообщения:
- [Текст кнопки + Ссылка] для одной кнопки.
- [Кнопка1 + Ссылка1][Кнопка2 + Ссылка2] для нескольких кнопок в одном ряду.
- Каждая строка представляет отдельный ряд кнопок.
Пример:
[Кнопка1 + https://example.com]
[Кнопка2 + https://example.org][Кнопка3 + https://example.net]
:param message: Строка с отформатированными ссылками.
:return: Список рядов кнопок, где каждый ряд это список кортежей (Текст, Ссылка).
"""
# Исправленное регулярное выражение для поиска [Текст + Ссылка]
pattern = re.compile(r"\[([^\[\]+]+)\s*\+\s*(https?://[^\[\]]+)\]")
# Инициализируем список рядов кнопок
keyboard_rows = []
# Разбиваем сообщение на строки
lines = message.strip().split("\n")
for line in lines:
# Находим все совпадения в строке
matches = pattern.findall(line)
if matches:
row = []
for text, url in matches:
button = (text.strip(), url.strip())
row.append(button)
keyboard_rows.append(row)
return keyboard_rows
+16
View File
@@ -0,0 +1,16 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello, this is the test webhook endpoint!"}
@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
return JSONResponse(content={"status": "ok", "data": data})
+95
View File
@@ -0,0 +1,95 @@
name: law-bot
x-default-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
tgbot:
build:
context: .
dockerfile: bot/Dockerfile
network: host
restart: unless-stopped
env_file:
- ./bot/.env
- ./postgres.env
environment:
- REDIS_URL=redis://redisdb:6379/0
- POSTGRES_HOST=postgredb
- POSTGRES_PORT=5432
- RAG_API_URL=http://api:8080
depends_on:
redisdb:
condition: service_healthy
postgredb:
condition: service_healthy
api:
condition: service_healthy
command: python aiogram_run.py
logging: *default-logging
redisdb:
image: redis:6-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
logging: *default-logging
chromadb:
image: chromadb/chroma:1.0.12
restart: unless-stopped
ports:
- "8000:8000"
environment:
- IS_PERSISTENT=TRUE
- PERSIST_DIRECTORY=/data
volumes:
- ./volumes/chroma:/data
logging: *default-logging
postgredb:
image: postgres:16-alpine
restart: unless-stopped
env_file:
- ./postgres.env
volumes:
- ./volumes/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 5
logging: *default-logging
api:
build:
context: .
dockerfile: api/Dockerfile
network: host
restart: unless-stopped
ports:
- "8080:8080"
env_file:
- ./api/.env
- ./postgres.env
environment:
- CHROMA_HOST=chromadb
- CHROMA_PORT=8000
volumes:
- ./volumes/huggingface:/root/.cache/huggingface
depends_on:
postgredb:
condition: service_healthy
chromadb:
condition: service_started
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=5)"]
interval: 10s
timeout: 5s
retries: 10
logging: *default-logging
+1373
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
"""CLI parser package for LawBot ingestion."""
+5
View File
@@ -0,0 +1,5 @@
from parser.cli import main
if __name__ == "__main__":
main()
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import argparse
import asyncio
from parser.discovery import discover_documents, build_session
from parser.fetcher import fetch_documents, load_manifest, load_raw_index
from parser.ingest import ingest_documents
from parser.normalizer import load_normalized_document, normalize_document, write_normalized_document
from shared import ORM
def parse_categories(value: str | None) -> set[str] | None:
if not value:
return None
return {item.strip() for item in value.split(",") if item.strip()}
def select_documents(categories: set[str] | None, limit: int | None) -> list[dict]:
manifest = load_manifest()
documents = manifest["documents"]
if categories:
documents = [doc for doc in documents if doc["category_key"] in categories]
if limit is not None:
documents = documents[:limit]
return documents
def run_discover(_: argparse.Namespace) -> None:
manifest = discover_documents(build_session())
print(f"discovered {len(manifest['documents'])} documents from {manifest['source_page']}")
def run_fetch(args: argparse.Namespace) -> None:
documents = select_documents(parse_categories(args.categories), args.limit)
payloads = fetch_documents(documents, force=args.force, dry_run=args.dry_run)
print(f"fetched {len(payloads)} documents")
def run_normalize(args: argparse.Namespace) -> None:
documents = select_documents(parse_categories(args.categories), args.limit)
raw_index = load_raw_index()
normalized_count = 0
for document in documents:
raw_payload = raw_index.get("documents", {}).get(document["key"])
if raw_payload is None:
raise FileNotFoundError(
f"raw payload for {document['key']} not found; run `python -m parser fetch` first"
)
normalized_document = normalize_document(raw_payload)
write_normalized_document(normalized_document, dry_run=args.dry_run)
normalized_count += 1
print(f"normalized {normalized_count} documents")
async def _run_ingest_async(args: argparse.Namespace) -> None:
documents = select_documents(parse_categories(args.categories), args.limit)
normalized_documents = []
for document in documents:
normalized = load_normalized_document(document["key"])
if normalized is None:
raise FileNotFoundError(
f"normalized payload for {document['key']} not found; run `python -m parser normalize` first"
)
normalized_documents.append(normalized)
orm = None
try:
if not args.dry_run:
orm = ORM()
await orm.init_schema()
results = await ingest_documents(orm, normalized_documents, dry_run=args.dry_run)
finally:
if orm is not None:
await orm.close()
print(f"ingested {len(results)} documents")
def run_ingest(args: argparse.Namespace) -> None:
asyncio.run(_run_ingest_async(args))
def run_pipeline(args: argparse.Namespace) -> None:
run_discover(args)
if args.dry_run:
run_fetch(args)
print("dry-run stopped after fetch preview; raw files and DB were not changed")
return
run_fetch(args)
run_normalize(args)
run_ingest(args)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Consultant ingestion pipeline for LawBot.")
subparsers = parser.add_subparsers(dest="command", required=True)
def add_common_flags(command_parser: argparse.ArgumentParser) -> None:
command_parser.add_argument("--categories", default=None)
command_parser.add_argument("--force", action="store_true")
command_parser.add_argument("--limit", type=int, default=None)
command_parser.add_argument("--dry-run", action="store_true")
discover_parser = subparsers.add_parser("discover")
discover_parser.set_defaults(func=run_discover)
fetch_parser = subparsers.add_parser("fetch")
add_common_flags(fetch_parser)
fetch_parser.set_defaults(func=run_fetch)
normalize_parser = subparsers.add_parser("normalize")
add_common_flags(normalize_parser)
normalize_parser.set_defaults(func=run_normalize)
ingest_parser = subparsers.add_parser("ingest")
add_common_flags(ingest_parser)
ingest_parser.set_defaults(func=run_ingest)
run_parser = subparsers.add_parser("run")
add_common_flags(run_parser)
run_parser.set_defaults(func=run_pipeline)
return parser
def main() -> None:
parser = build_parser()
args = parser.parse_args()
args.func(args)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
VOLUMES_DIR = BASE_DIR / "volumes" / "parser"
RAW_ROOT = VOLUMES_DIR / "raw" / "consultant"
NORMALIZED_ROOT = VOLUMES_DIR / "normalized"
STATE_DIR = VOLUMES_DIR / "state"
MANIFEST_PATH = STATE_DIR / "manifest.json"
RAW_INDEX_PATH = STATE_DIR / "raw_index.json"
POPULAR_URL = "https://www.consultant.ru/popular/"
REQUEST_TIMEOUT = 30
MAX_RETRIES = 3
MAX_WORKERS = 2
USER_AGENT = (
"LawBotParser/1.0 (+https://local.lawbot; "
"purpose=legal-rag-ingestion; contact=local-dev)"
)
TARGET_DOCUMENTS = [
{
"key": "constitution",
"category_key": "constitution",
"consultant_category": "Конституция РФ",
"law_type": "constitutional",
"source_short_name": "Конституция РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_28399/",
},
{
"key": "civil_code_part_1",
"category_key": "civil",
"consultant_category": "Гражданское право, гражданское законодательство РФ",
"law_type": "civil",
"source_short_name": "ГК РФ ч. 1",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_5142/",
},
{
"key": "civil_code_part_2",
"category_key": "civil",
"consultant_category": "Гражданское право, гражданское законодательство РФ",
"law_type": "civil",
"source_short_name": "ГК РФ ч. 2",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_508506/",
},
{
"key": "civil_code_part_3",
"category_key": "civil",
"consultant_category": "Гражданское право, гражданское законодательство РФ",
"law_type": "civil",
"source_short_name": "ГК РФ ч. 3",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_482694/",
},
{
"key": "civil_code_part_4",
"category_key": "civil",
"consultant_category": "Гражданское право, гражданское законодательство РФ",
"law_type": "civil",
"source_short_name": "ГК РФ ч. 4",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_509417/",
},
{
"key": "civil_procedure_code",
"category_key": "civil_procedure",
"consultant_category": "Гражданское процессуальное право, гражданский процесс",
"law_type": "procedural",
"source_short_name": "ГПК РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_39570/",
},
{
"key": "housing_code",
"category_key": "housing",
"consultant_category": "Жилищное право, жилищное законодательство РФ",
"law_type": "housing",
"source_short_name": "ЖК РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_51057/",
},
{
"key": "family_code",
"category_key": "family",
"consultant_category": "Семейное право, семейное законодательство РФ",
"law_type": "family",
"source_short_name": "СК РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_8982/",
},
{
"key": "labor_code",
"category_key": "labor",
"consultant_category": "Трудовое право, трудовое законодательство РФ",
"law_type": "labor",
"source_short_name": "ТК РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_34683/",
},
{
"key": "consumer_protection_law",
"category_key": "consumer",
"consultant_category": "Законодательство РФ о правах потребителя",
"law_type": "consumer",
"source_short_name": "ЗОЗПП",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_305/",
},
{
"key": "enforcement_law",
"category_key": "enforcement",
"consultant_category": "Законодательство об исполнительном производстве",
"law_type": "enforcement",
"source_short_name": "ФЗ об исполнительном производстве",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_71450/",
},
{
"key": "mortgage_law",
"category_key": "mortgage",
"consultant_category": "Законодательство об ипотеке",
"law_type": "mortgage",
"source_short_name": "ФЗ об ипотеке",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_19396/",
},
{
"key": "administrative_code",
"category_key": "administrative",
"consultant_category": "Административное право, административное законодательство РФ",
"law_type": "administrative",
"source_short_name": "КоАП РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_34661/",
},
{
"key": "criminal_code",
"category_key": "criminal",
"consultant_category": "Уголовное право, уголовное законодательство РФ",
"law_type": "criminal",
"source_short_name": "УК РФ",
"source_url": "https://www.consultant.ru/document/cons_doc_LAW_10699/",
},
]
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timezone
import requests
from bs4 import BeautifulSoup
from parser.config import MANIFEST_PATH, POPULAR_URL, TARGET_DOCUMENTS, USER_AGENT
from parser.utils import to_absolute_url, write_json
def discover_documents(session: requests.Session) -> dict:
response = session.get(POPULAR_URL, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
anchors = {
anchor.get_text(" ", strip=True): to_absolute_url(anchor.get("href", ""), POPULAR_URL)
for anchor in soup.select("a[href]")
}
grouped: dict[str, list[dict]] = defaultdict(list)
for document in TARGET_DOCUMENTS:
grouped[document["category_key"]].append(document)
categories = []
for category_key, docs in grouped.items():
category_title = docs[0]["consultant_category"]
categories.append(
{
"key": category_key,
"title": category_title,
"found_on_popular_page": category_title in anchors,
"documents": [
{
"key": doc["key"],
"source_url": doc["source_url"],
"law_type": doc["law_type"],
"source_short_name": doc["source_short_name"],
}
for doc in docs
],
}
)
manifest = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"source_page": POPULAR_URL,
"categories": sorted(categories, key=lambda item: item["title"]),
"documents": TARGET_DOCUMENTS,
}
write_json(MANIFEST_PATH, manifest)
return manifest
def build_session() -> requests.Session:
session = requests.Session()
session.headers.update({"User-Agent": USER_AGENT})
return session
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
import requests
from bs4 import BeautifulSoup, Tag
from parser.config import (
MANIFEST_PATH,
MAX_RETRIES,
MAX_WORKERS,
RAW_INDEX_PATH,
RAW_ROOT,
REQUEST_TIMEOUT,
)
from parser.discovery import build_session, discover_documents
from parser.utils import ensure_dir, read_json, sha256_text, to_absolute_url, write_json
def fetch_with_retry(session: requests.Session, url: str) -> str:
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
response = session.get(url, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
return response.text
except Exception as exc: # pragma: no cover - network branch
last_error = exc
if attempt < MAX_RETRIES:
time.sleep(attempt)
raise last_error # type: ignore[misc]
def extract_toc_articles(root_html: str, source_url: str) -> list[dict]:
soup = BeautifulSoup(root_html, "html.parser")
toc = soup.select_one(".document-page__toc > ul")
if toc is None:
return []
articles: list[dict] = []
def walk_list(node: Tag, stack: list[str]) -> None:
items = [child for child in node.children if isinstance(child, Tag) and child.name == "li"]
for li in items:
anchor = li.find("a", href=True, recursive=False)
if anchor is None:
continue
title = anchor.get_text(" ", strip=True)
url = to_absolute_url(anchor["href"], source_url)
next_ul = li.find_next_sibling("ul")
if title.startswith("Статья "):
article_number, _, article_title = title.partition(". ")
articles.append(
{
"article_number": article_number.replace("Статья", "").strip(),
"article_title": article_title.strip() or title,
"article_url": url,
"section_title": next((value for value in reversed(stack) if value.startswith("Раздел")), None),
"chapter_title": next((value for value in reversed(stack) if value.startswith("Глава")), None),
"part_title": next((value for value in reversed(stack) if value.startswith("Часть")), None),
"breadcrumb": [*stack, title],
}
)
else:
if next_ul is not None:
walk_list(next_ul, [*stack, title])
walk_list(toc, [])
return articles
def _fetch_article(session: requests.Session, article: dict) -> tuple[dict, str]:
return article, fetch_with_retry(session, article["article_url"])
def load_manifest() -> dict:
manifest = read_json(MANIFEST_PATH)
if manifest is None:
manifest = discover_documents(build_session())
return manifest
def load_raw_index() -> dict:
return read_json(RAW_INDEX_PATH, default={"documents": {}})
def save_raw_index(index_payload: dict) -> None:
write_json(RAW_INDEX_PATH, index_payload)
def fetch_documents(
selected_documents: list[dict], force: bool = False, dry_run: bool = False
) -> list[dict]:
session = build_session()
raw_index = load_raw_index()
raw_index.setdefault("documents", {})
fetched_payloads: list[dict] = []
for document in selected_documents:
root_html = fetch_with_retry(session, document["source_url"])
root_hash = sha256_text(root_html)
previous = raw_index["documents"].get(document["key"])
if (
not force
and previous
and previous.get("root_hash") == root_hash
and Path(previous["raw_dir"]).exists()
):
fetched_payloads.append(previous)
continue
toc_articles = extract_toc_articles(root_html, document["source_url"])
timestamp = datetime.now(timezone.utc)
raw_dir = RAW_ROOT / timestamp.date().isoformat() / document["key"]
article_dir = raw_dir / "articles"
article_payloads: list[dict] = []
article_hash_parts: list[str] = []
if not dry_run:
ensure_dir(article_dir)
(raw_dir / "root.html").write_text(root_html, encoding="utf-8")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = {
executor.submit(_fetch_article, session, article): index
for index, article in enumerate(toc_articles)
}
article_results = [None] * len(futures)
for future in as_completed(futures):
article, article_html = future.result()
index = futures[future]
article_hash = sha256_text(article_html)
article_hash_parts.append(article_hash)
article_payload = {
**article,
"file_name": f"{index:04d}.html",
"sha256": article_hash,
}
article_results[index] = (article_payload, article_html)
for article_payload, article_html in article_results:
article_payloads.append(article_payload)
if not dry_run:
(article_dir / article_payload["file_name"]).write_text(article_html, encoding="utf-8")
payload = {
**document,
"fetched_at": timestamp.isoformat(),
"raw_dir": str(raw_dir),
"root_file": "root.html",
"root_hash": root_hash,
"version_hash": sha256_text(root_hash + "".join(article_hash_parts)),
"article_count": len(article_payloads),
"articles": article_payloads,
}
if not dry_run:
write_json(raw_dir / "sidecar.json", payload)
raw_index["documents"][document["key"]] = payload
save_raw_index(raw_index)
fetched_payloads.append(payload)
return fetched_payloads
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from parser.normalizer import build_chunks
from shared import ORM
def parse_iso_date(value: str | None) -> date | None:
if not value:
return None
return date.fromisoformat(value)
async def ingest_documents(
orm: ORM | None, normalized_documents: list[dict], dry_run: bool = False
) -> list[dict]:
results = []
for document in normalized_documents:
source_payload = {
"title": document["title"],
"source_type": document["source_type"],
"jurisdiction": "RU",
"law_type": document["law_type"],
"document_number": document["document_number"],
"adoption_date": parse_iso_date(document["adoption_date"]),
"publication_date": parse_iso_date(document["publication_date"]),
"effective_date": parse_iso_date(document["effective_date"]),
"source_url": document["source_url"],
"official_publication_number": None,
"version_hash": document["version_hash"],
"is_active": True,
"loaded_at": datetime.now(timezone.utc),
}
chunks = build_chunks(document)
if dry_run:
results.append(
{
"document_key": document["key"],
"status": "dry-run",
"chunk_count": len(chunks),
}
)
continue
if orm is None:
raise RuntimeError("ORM instance is required when dry_run is disabled")
source, created = await orm.upsert_law_source(source_payload)
should_replace = created
if not created:
existing_count = await orm.get_chunks_count_by_source(source.id)
should_replace = existing_count == 0
if should_replace:
await orm.replace_law_chunks(source.id, chunks)
results.append(
{
"document_key": document["key"],
"status": "updated" if should_replace else "skipped",
"chunk_count": len(chunks),
"source_id": source.id,
}
)
return results
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
import re
from pathlib import Path
from bs4 import BeautifulSoup
from parser.config import NORMALIZED_ROOT
from parser.utils import (
chunk_paragraphs,
ensure_dir,
normalize_text,
parse_russian_date,
read_json,
write_json,
)
DOCUMENT_NUMBER_RE = re.compile(r"N\s*([0-9А-Яа-я\-ФКЗA-Z]+)")
def parse_root_metadata(root_html: str) -> dict:
soup = BeautifulSoup(root_html, "html.parser")
title_node = soup.select_one(".document-page__title h1")
title_text = title_node.get_text("\n", strip=True) if title_node else ""
content_node = soup.select_one(".document-page__content")
first_text = content_node.get_text("\n", strip=True) if content_node else ""
document_number = None
number_match = DOCUMENT_NUMBER_RE.search(first_text or title_text)
if number_match:
document_number = number_match.group(1)
adoption_date = parse_russian_date(first_text or title_text)
effective_date = None
effective_match = re.search(r"вступ\.\s*в силу с\s*(\d{2}\.\d{2}\.\d{4})", title_text)
if effective_match:
day, month, year = effective_match.group(1).split(".")
effective_date = f"{year}-{month}-{day}"
lines = [line.strip() for line in title_text.splitlines() if line.strip()]
return {
"title": lines[0] if lines else title_text.strip(),
"version_note": "\n".join(lines[1:]).strip() or None,
"document_number": document_number,
"adoption_date": adoption_date,
"effective_date": effective_date,
"publication_date": None,
}
def parse_article_page(article_html: str) -> list[str]:
soup = BeautifulSoup(article_html, "html.parser")
content = soup.select_one(".document-page__content")
if content is None:
return []
for selector in [
".document__insert",
".document__edit",
".document-page__notes",
".document-page__title-link",
".document-page__title",
]:
for node in content.select(selector):
node.decompose()
heading = content.select_one("h1")
if heading is not None:
heading.decompose()
paragraphs: list[str] = []
for node in content.select("p"):
text = normalize_text(node.get_text("\n", strip=True))
if not text:
continue
paragraphs.append(text)
return paragraphs
def normalize_document(raw_payload: dict) -> dict:
raw_dir = Path(raw_payload["raw_dir"])
root_html = (raw_dir / raw_payload["root_file"]).read_text(encoding="utf-8")
metadata = parse_root_metadata(root_html)
articles = []
for article_ref in raw_payload["articles"]:
article_html = (raw_dir / "articles" / article_ref["file_name"]).read_text(encoding="utf-8")
paragraphs = parse_article_page(article_html)
article_text = "\n\n".join(paragraphs).strip()
articles.append(
{
"article_number": article_ref["article_number"],
"article_title": article_ref["article_title"],
"article_url": article_ref["article_url"],
"section_title": article_ref.get("section_title"),
"chapter_title": article_ref.get("chapter_title"),
"part_title": article_ref.get("part_title"),
"breadcrumb": article_ref.get("breadcrumb", []),
"text": article_text,
"paragraphs": paragraphs,
}
)
normalized = {
"key": raw_payload["key"],
"title": metadata["title"],
"source_url": raw_payload["source_url"],
"source_type": "consultant_document",
"document_number": metadata["document_number"],
"adoption_date": metadata["adoption_date"],
"publication_date": metadata["publication_date"],
"effective_date": metadata["effective_date"],
"version_note": metadata["version_note"],
"version_hash": raw_payload["version_hash"],
"law_type": raw_payload["law_type"],
"consultant_category": raw_payload["consultant_category"],
"source_short_name": raw_payload["source_short_name"],
"articles": articles,
}
return normalized
def build_chunks(normalized_document: dict) -> list[dict]:
chunks: list[dict] = []
for article in normalized_document["articles"]:
paragraphs = article["paragraphs"] or ([article["text"]] if article["text"] else [])
text_chunks = chunk_paragraphs(paragraphs)
if not text_chunks and article["text"]:
text_chunks = [article["text"]]
for chunk_text in text_chunks:
chunks.append(
{
"chunk_index": len(chunks),
"article_number": article["article_number"],
"article_title": article["article_title"],
"chunk_text": chunk_text,
"metadata": {
"source_title": normalized_document["title"],
"source_short_name": normalized_document["source_short_name"],
"consultant_category": normalized_document["consultant_category"],
"chapter_title": article.get("chapter_title"),
"section_title": article.get("section_title"),
"article_number": article["article_number"],
"article_title": article["article_title"],
"document_url": article["article_url"],
"breadcrumb": article.get("breadcrumb", []),
"version_hash": normalized_document["version_hash"],
},
}
)
return chunks
def write_normalized_document(normalized_document: dict, dry_run: bool = False) -> Path:
output_path = NORMALIZED_ROOT / f"{normalized_document['key']}.json"
if not dry_run:
ensure_dir(output_path.parent)
write_json(output_path, normalized_document)
return output_path
def load_normalized_document(document_key: str) -> dict | None:
return read_json(NORMALIZED_ROOT / f"{document_key}.json", default=None)
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urljoin
WHITESPACE_RE = re.compile(r"[ \t]+")
NEWLINES_RE = re.compile(r"\n{3,}")
RUSSIAN_MONTHS = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
def ensure_dir(path: Path) -> Path:
path.mkdir(parents=True, exist_ok=True)
return path
def read_json(path: Path, default: Any = None) -> Any:
if not path.exists():
return default
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, payload: Any) -> None:
ensure_dir(path.parent)
path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=False),
encoding="utf-8",
)
def sha256_text(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def normalize_text(value: str) -> str:
cleaned = value.replace("\xa0", " ")
cleaned = WHITESPACE_RE.sub(" ", cleaned)
cleaned = re.sub(r" *\n *", "\n", cleaned)
cleaned = NEWLINES_RE.sub("\n\n", cleaned)
return cleaned.strip()
def slugify(value: str) -> str:
slug = re.sub(r"[^a-zA-Z0-9]+", "-", value.lower()).strip("-")
return slug or "document"
def to_absolute_url(url: str, base_url: str) -> str:
return urljoin(base_url, url)
def parse_russian_date(value: str) -> str | None:
match = re.search(r"(\d{1,2})\s+([а-я]+)\s+(\d{4})", value.lower())
if not match:
return None
day = int(match.group(1))
month = RUSSIAN_MONTHS.get(match.group(2))
year = int(match.group(3))
if month is None:
return None
return datetime(year, month, day).date().isoformat()
def chunk_paragraphs(
paragraphs: list[str], max_chars: int = 4500, overlap_paragraphs: int = 1
) -> list[str]:
if not paragraphs:
return []
chunks: list[str] = []
current: list[str] = []
current_len = 0
for paragraph in paragraphs:
paragraph_len = len(paragraph)
if current and current_len + paragraph_len + 2 > max_chars:
chunks.append("\n\n".join(current).strip())
current = current[-overlap_paragraphs:] if overlap_paragraphs else []
current_len = sum(len(item) + 2 for item in current)
current.append(paragraph)
current_len += paragraph_len + 2
if current:
chunks.append("\n\n".join(current).strip())
return [chunk for chunk in chunks if chunk]
+9
View File
@@ -0,0 +1,9 @@
# Postgres database used by bot, parser and API
POSTGRES_DB=law_bot_db
POSTGRES_USER=lawbot_user
POSTGRES_PASSWORD=change_me
# Inside docker compose keep the service name.
# For local host-based tools you can override it with localhost.
POSTGRES_HOST=postgredb
POSTGRES_PORT=5432
+782
View File
@@ -0,0 +1,782 @@
from __future__ import annotations
from docx import Document
from docx.enum.section import WD_SECTION
from docx.enum.table import WD_TABLE_ALIGNMENT
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
from pathlib import Path
OUTPUT_PATH = Path("protocol/Протокол_курсовой_работы_LawBot.docx")
BLACK = RGBColor(0, 0, 0)
def apply_run_format(run, *, bold: bool | None = None, font_size: int | None = None) -> None:
if bold is not None:
run.bold = bold
if font_size is not None:
run.font.size = Pt(font_size)
run.font.name = "Times New Roman"
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
run.font.color.rgb = BLACK
run.font.underline = False
def set_cell_text(cell, text: str, align=WD_ALIGN_PARAGRAPH.CENTER, bold: bool = False, font_size=12):
cell.text = ""
paragraph = cell.paragraphs[0]
paragraph.alignment = align
run = paragraph.add_run(text)
apply_run_format(run, bold=bold, font_size=font_size)
def configure_document(document: Document) -> None:
section = document.sections[0]
section.page_width = Cm(21)
section.page_height = Cm(29.7)
section.top_margin = Cm(2)
section.bottom_margin = Cm(2)
section.left_margin = Cm(3)
section.right_margin = Cm(1.5)
section.different_first_page_header_footer = True
normal = document.styles["Normal"]
normal.font.name = "Times New Roman"
normal._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
normal.font.size = Pt(14)
normal.font.color.rgb = BLACK
normal.paragraph_format.first_line_indent = Cm(1.25)
normal.paragraph_format.line_spacing = 1.5
normal.paragraph_format.space_after = Pt(0)
normal.paragraph_format.space_before = Pt(0)
for style_name in ["Heading 1", "Heading 2", "Heading 3"]:
style = document.styles[style_name]
style.font.name = "Times New Roman"
style._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
style.font.size = Pt(14)
style.font.bold = True
style.font.color.rgb = BLACK
style.font.underline = False
style.paragraph_format.first_line_indent = Cm(0)
style.paragraph_format.line_spacing = 1.5
style.paragraph_format.space_before = Pt(12)
style.paragraph_format.space_after = Pt(6)
footer = section.footer
paragraph = footer.paragraphs[0]
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
add_page_number_field(paragraph)
def add_page_number_field(paragraph) -> None:
run = paragraph.add_run()
fld_begin = OxmlElement("w:fldChar")
fld_begin.set(qn("w:fldCharType"), "begin")
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = " PAGE "
fld_separate = OxmlElement("w:fldChar")
fld_separate.set(qn("w:fldCharType"), "separate")
fld_end = OxmlElement("w:fldChar")
fld_end.set(qn("w:fldCharType"), "end")
run._r.append(fld_begin)
run._r.append(instr_text)
run._r.append(fld_separate)
run._r.append(fld_end)
def add_toc(paragraph) -> None:
run = paragraph.add_run()
fld_begin = OxmlElement("w:fldChar")
fld_begin.set(qn("w:fldCharType"), "begin")
instr_text = OxmlElement("w:instrText")
instr_text.set(qn("xml:space"), "preserve")
instr_text.text = ' TOC \\o "1-3" \\h \\z \\u '
fld_separate = OxmlElement("w:fldChar")
fld_separate.set(qn("w:fldCharType"), "separate")
placeholder = OxmlElement("w:t")
placeholder.text = "Обновите поле содержания в Microsoft Word (ПКМ -> Обновить поле)."
fld_end = OxmlElement("w:fldChar")
fld_end.set(qn("w:fldCharType"), "end")
run._r.append(fld_begin)
run._r.append(instr_text)
run._r.append(fld_separate)
run._r.append(placeholder)
run._r.append(fld_end)
def add_title_page(document: Document) -> None:
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
apply_run_format(p.add_run("МИНОБРНАУКИ РОССИИ"), bold=True, font_size=14)
for line in [
"Федеральное государственное бюджетное образовательное учреждение",
"высшего образования",
"_______________________________",
"_______________________________",
]:
paragraph = document.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = paragraph.add_run(line)
apply_run_format(run, font_size=14)
document.add_paragraph()
document.add_paragraph()
document.add_paragraph()
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run("ПРОТОКОЛ КУРСОВОЙ РАБОТЫ")
apply_run_format(run, bold=True, font_size=16)
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(
"на тему «Разработка Telegram-бота для юридических консультаций\n"
"на основе технологии Retrieval-Augmented Generation»"
)
apply_run_format(run, font_size=14)
document.add_paragraph()
document.add_paragraph()
table = document.add_table(rows=4, cols=2)
table.alignment = WD_TABLE_ALIGNMENT.RIGHT
table.columns[0].width = Cm(5.5)
table.columns[1].width = Cm(7.5)
rows = [
("Выполнил:", "студент группы ____________"),
("Ф.И.О.:", "__________________________"),
("Руководитель:", "__________________________"),
("Дата:", "__________________________"),
]
for row, (left, right) in zip(table.rows, rows):
set_cell_text(row.cells[0], left, align=WD_ALIGN_PARAGRAPH.LEFT, bold=False, font_size=14)
set_cell_text(row.cells[1], right, align=WD_ALIGN_PARAGRAPH.LEFT, bold=False, font_size=14)
document.add_paragraph()
document.add_paragraph()
document.add_paragraph()
document.add_paragraph()
document.add_paragraph()
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
apply_run_format(p.add_run("Волгоград, 2026"), font_size=14)
document.add_page_break()
def add_heading(document: Document, text: str, level: int = 1) -> None:
paragraph = document.add_heading(text, level=level)
paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
for run in paragraph.runs:
apply_run_format(run, bold=True, font_size=14)
def add_paragraph(document: Document, text: str, align=WD_ALIGN_PARAGRAPH.JUSTIFY) -> None:
paragraph = document.add_paragraph(text)
paragraph.alignment = align
def add_list(document: Document, items: list[str]) -> None:
for index, item in enumerate(items, start=1):
paragraph = document.add_paragraph(f"{index}. {item}")
paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
paragraph.paragraph_format.first_line_indent = Cm(0)
paragraph.paragraph_format.left_indent = Cm(1.25)
def add_figure_placeholder(document: Document, number: int, title: str, marker: str) -> None:
p = document.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run(f"[МЕСТО ДЛЯ {marker.upper()}]")
apply_run_format(run, bold=True, font_size=14)
caption = document.add_paragraph()
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = caption.add_run(f"Рисунок {number} {title}")
apply_run_format(run, font_size=14)
def add_table_with_caption(
document: Document,
number: int,
title: str,
headers: list[str],
rows: list[list[str]],
) -> None:
caption = document.add_paragraph()
caption.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = caption.add_run(f"Таблица {number} {title}")
apply_run_format(run, font_size=14)
table = document.add_table(rows=len(rows) + 1, cols=len(headers))
table.style = "Table Grid"
table.alignment = WD_TABLE_ALIGNMENT.CENTER
for cell, header in zip(table.rows[0].cells, headers):
set_cell_text(cell, header, bold=True)
for row_obj, row_data in zip(table.rows[1:], rows):
for cell, value in zip(row_obj.cells, row_data):
set_cell_text(cell, value, align=WD_ALIGN_PARAGRAPH.LEFT)
document.add_paragraph()
def build_document() -> None:
document = Document()
configure_document(document)
add_title_page(document)
add_heading(document, "СОДЕРЖАНИЕ", level=1)
toc_paragraph = document.add_paragraph()
toc_paragraph.alignment = WD_ALIGN_PARAGRAPH.LEFT
add_toc(toc_paragraph)
document.add_page_break()
add_heading(document, "ВВЕДЕНИЕ", level=1)
add_paragraph(
document,
"Развитие цифровых сервисов и широкое распространение генеративных моделей "
"искусственного интеллекта создают предпосылки для появления специализированных "
"информационных систем, способных предоставлять пользователю понятные и быстрые "
"консультации в узких предметных областях. Одной из таких областей является "
"юриспруденция, где особенно важны актуальность источников, проверяемость ответа "
"и возможность сослаться на конкретную норму права.",
)
add_paragraph(
document,
"В рамках курсовой работы разработан Telegram-бот LawBot, предназначенный для "
"консультирования пользователей по бытовым юридическим вопросам в правовом поле "
"Российской Федерации. Система реализует подход Retrieval-Augmented Generation: "
"пользовательский запрос классифицируется, затем по локальной базе нормативных "
"документов выполняется гибридный поиск, после чего большая языковая модель "
"формирует ответ только по найденным источникам. Такой подход позволяет объединить "
"преимущества генеративных моделей и контролируемого поиска по локальному корпусу.",
)
add_paragraph(
document,
"Объектом исследования является процесс автоматизации первичной юридической "
"консультации в Telegram. Предметом исследования являются методы проектирования "
"чат-ботов, механизмы гибридного поиска по правовым документам и способы "
"организации RAG-контура для прикладной юридической системы.",
)
add_paragraph(
document,
"Цель работы состоит в повышении доступности правовой информации для пользователя "
"за счет разработки Telegram-бота, который принимает вопрос, подбирает релевантные "
"нормы закона и объясняет их простым языком с сохранением ссылок на источники.",
)
add_paragraph(document, "Для достижения поставленной цели решены следующие задачи:")
add_list(
document,
[
"проведен анализ предметной области и существующих решений в области юридических чат-ботов;",
"спроектированы сценарии использования, функциональные требования и архитектура системы;",
"реализованы Telegram-бот, парсер нормативных документов, общий слой доступа к данным и RAG API;",
"выполнено тестирование разработанного программного продукта.",
],
)
add_paragraph(
document,
"Работа состоит из четырех глав, заключения и списка использованных источников. "
"В документе содержатся 10 рисунков и 4 таблицы. Первая глава посвящена анализу "
"предметной области и подходов к разработке чат-ботов, во второй главе рассмотрены "
"вопросы проектирования, в третьей главе описана реализация, а в четвертой приведены "
"результаты тестирования.",
)
add_heading(document, "1 АНАЛИЗ ПРЕДМЕТНОЙ ОБЛАСТИ", level=1)
add_heading(document, "1.1 Характеристика предметной области", level=2)
add_paragraph(
document,
"Предметной областью проекта является автоматизированная первичная юридическая "
"консультация по законодательству Российской Федерации. В отличие от развлекательных "
"или сервисных чат-ботов, юридический бот должен обеспечивать не только корректный "
"диалог, но и высокую точность подбора правовой нормы, прозрачность происхождения "
"ответа и сохранение истории взаимодействия. Ошибочный ответ в данной области может "
"привести пользователя к неверным действиям, поэтому в системе сделан акцент на "
"локальный корпус документов и обязательную ссылку на найденный источник.",
)
add_paragraph(
document,
"Для MVP-версии были выбраны наиболее частые бытовые категории вопросов: трудовые "
"отношения, защита прав потребителей, жилье и аренда, семейные споры, долги и займы, "
"договорные отношения, вопросы суда и процесса, а также обобщенная категория "
"«Другое». В качестве документной базы используются федеральные нормативные акты: "
"Конституция Российской Федерации, Гражданский кодекс РФ (части 1–4), Гражданский "
"процессуальный кодекс РФ, Жилищный кодекс РФ, Семейный кодекс РФ, Трудовой кодекс РФ, "
"Закон РФ «О защите прав потребителей», Федеральный закон «Об исполнительном "
"производстве», Федеральный закон «Об ипотеке» и КоАП РФ. Источником для первичного "
"получения текстов выбран раздел популярных документов на сайте КонсультантПлюс [1].",
)
add_paragraph(
document,
"Основная проблема пользователя в такой системе заключается в том, что ему сложно "
"самостоятельно определить нужный нормативный акт, статью и актуальную редакцию "
"документа. Кроме того, нормативный язык зачастую сложен для восприятия. Следовательно, "
"бот должен решать сразу две задачи: находить релевантные правовые фрагменты и "
"объяснять их доступным языком, не выходя за пределы найденных источников.",
)
add_heading(document, "1.2 Анализ существующих решений", level=2)
add_paragraph(
document,
"В области юридических информационных сервисов можно выделить несколько классов "
"решений: генеративные универсальные ИИ-ассистенты, rule-based FAQ-боты, "
"retrieval-ориентированные справочные системы и гибридные системы, совмещающие "
"поиск и генерацию. На практике каждое решение имеет собственные преимущества и "
"ограничения.",
)
add_table_with_caption(
document,
1,
"Сравнение существующих решений и подходов",
["Решение", "Подход", "Преимущества", "Ограничения"],
[
[
"ChatGPT, GigaChat и аналогичные LLM-ассистенты",
"generation-based",
"Естественный диалог, гибкость формулировок, хорошие пояснения",
"Без внешнего поиска возможны галлюцинации и отсутствие точных ссылок на нормы",
],
[
"FAQ-боты государственных и коммерческих сервисов",
"rule-based",
"Предсказуемость ответов, простая проверяемость сценариев",
"Ограниченный охват, слабая работа со свободным текстом",
],
[
"Справочно-правовые системы",
"retrieval-based",
"Высокая полнота поиска по нормативным актам, удобная работа с документами",
"Требуют самостоятельного анализа текста пользователем, не ориентированы на диалог в Telegram",
],
[
"Гибридные RAG-системы",
"hybrid",
"Сочетают поиск по корпусу и генерацию понятного ответа",
"Сложнее в проектировании, требуют отдельного контура индексации и контроля качества",
],
],
)
add_paragraph(
document,
"Для юридической области pure generation-подход является недостаточно надежным, "
"поскольку модель может предложить правдоподобный, но не подтвержденный нормой "
"закона ответ. Чисто rule-based подход, напротив, слишком ограничен и плохо подходит "
"для свободно сформулированных пользовательских ситуаций. Справочно-правовые системы "
"эффективны как поиск по документам, однако требуют от пользователя самостоятельного "
"изучения и интерпретации результатов. По этим причинам наиболее рациональным для "
"поставленной задачи является гибридный RAG-подход.",
)
add_heading(document, "1.3 Обоснование выбранного подхода", level=2)
add_paragraph(
document,
"В проекте выбран гибридный сценарий, в котором генеративная модель используется для "
"классификации запроса, генерации поисковых фраз и финального формулирования ответа, "
"а поиск выполняется по локальной базе нормативных актов. На первом этапе пользовательский "
"вопрос дополняется контекстом категории, региона и типа пользователя. На втором этапе "
"LLM формирует несколько поисковых фраз и предварительные фильтры. На третьем этапе "
"система выполняет full-text поиск в PostgreSQL и vector search в ChromaDB, объединяет "
"результаты и отбирает наиболее релевантные фрагменты. Финальный ответ формируется "
"исключительно по этим фрагментам.",
)
add_paragraph(
document,
"Выбранная схема обеспечивает компромисс между качеством диалога и контролем над "
"источниками. Она хорошо масштабируется: можно добавлять новые документы, повторно "
"индексировать корпус, улучшать embedding-модель, не меняя пользовательский интерфейс. "
"Именно поэтому данный подход выбран как основа архитектуры LawBot.",
)
add_heading(document, "2 ПРОЕКТИРОВАНИЕ ЧАТ-БОТА", level=1)
add_heading(document, "2.1 Сценарии использования системы", level=2)
add_paragraph(
document,
"Основным актором системы является пользователь Telegram, который может начать новую "
"консультацию, выбрать категорию вопроса, описать ситуацию, указать регион, получить "
"ответ с найденными источниками и сохранить консультацию в историю. Дополнительным "
"актором является администратор, имеющий доступ к административным функциям управления. "
"Также в архитектуре присутствуют внешние сервисы: источник нормативных документов, "
"LLM-провайдер и сервисы хранения.",
)
add_figure_placeholder(
document,
1,
"Диаграмма вариантов использования Telegram-бота LawBot",
"диаграммы use case",
)
add_paragraph(
document,
"Ключевой пользовательский сценарий включает последовательность «выбор категории – "
"описание ситуации – указание региона – поиск по базе – выдача ответа – сохранение "
"в историю». Отдельно поддерживается сценарий продолжения ранее начатой консультации, "
"в котором система учитывает краткую историю сообщений и использует ее как дополнительный "
"контекст для генерации ответа.",
)
add_heading(document, "2.2 Функциональные требования", level=2)
add_table_with_caption(
document,
2,
"Ключевые функциональные требования к системе",
["Группа требований", "Содержание"],
[
[
"Пользовательский интерфейс",
"Система должна поддерживать команды /start, главное меню, категории вопросов, профиль и историю консультаций.",
],
[
"Юридический поиск",
"Система должна выполнять гибридный поиск по локальному корпусу правовых документов и учитывать категорию, регион и тип пользователя.",
],
[
"Формирование ответа",
"Система должна формировать ответ простым языком только по найденным источникам и отображать ссылки на используемые нормы.",
],
[
"Управление данными",
"Система должна хранить пользователей, консультации, сообщения, список документов, чанки и историю RAG-запросов.",
],
[
"Подготовка корпуса",
"Система должна скачивать, нормализовать и загружать тексты законов с сохранением версионности и исходного HTML.",
],
],
)
add_figure_placeholder(
document,
2,
"Диаграмма требований к системе LawBot",
"диаграммы requirements",
)
add_paragraph(
document,
"Сценарии и требования были согласованы с задачами курсовой работы. Особое внимание "
"уделено функции контроля источников: бот не должен ссылаться на правовые нормы, которых "
"нет среди найденных RAG-фрагментов. Это требование является критически важным для "
"юридической предметной области.",
)
add_heading(document, "2.3 Архитектура решения", level=2)
add_paragraph(
document,
"Архитектура системы состоит из пяти логических подсистем: Telegram-бота, парсера, "
"общего слоя доступа к базе данных, RAG API и подсистем хранения. Telegram-бот "
"реализован на aiogram [2] и отвечает за пользовательский диалог. Парсер выделен в "
"отдельный CLI-модуль, который подготавливает нормативный корпус и сохраняет его в "
"PostgreSQL [5]. FastAPI-сервис [3] выполняет индексацию в ChromaDB [6], гибридный "
"поиск, классификацию запроса и формирование ответа. Общие модели и репозитории "
"вынесены в пакет shared, чтобы разные сервисы работали с единым описанием данных.",
)
add_figure_placeholder(
document,
3,
"Компонентная диаграмма системы LawBot",
"компонентной диаграммы",
)
add_paragraph(
document,
"Физическое развертывание выполняется в Docker Compose [10]. В составе стенда работают "
"контейнеры tgbot, api, postgredb, redisdb и chromadb. Для каждого сервиса задан healthcheck, "
"а для логов используется драйвер json-file с ротацией. Для embedding-модели дополнительно "
"прокинут volume, чтобы модель не загружалась заново после каждого перезапуска.",
)
add_figure_placeholder(
document,
4,
"Схема развертывания контейнеров в Docker Compose",
"схемы развертывания",
)
add_heading(document, "2.4 Проектирование хранилища данных", level=2)
add_paragraph(
document,
"Для хранения структурированных данных выбрана реляционная СУБД PostgreSQL [5]. "
"В системе используются таблицы users, consultations, messages, law_sources, law_chunks "
"и rag_queries. Таблица users хранит профиль пользователя Telegram, consultations и messages "
"описывают историю взаимодействия, law_sources и law_chunks содержат нормативные документы "
"и их фрагменты, а rag_queries фиксирует сгенерированные поисковые фразы и найденные чанки. "
"Для полнотекстового поиска в PostgreSQL используется поле tsv с русской морфологией.",
)
add_paragraph(
document,
"Векторный индекс вынесен в ChromaDB [6]. Такое разделение позволяет хранить канонический "
"текст документов в PostgreSQL и независимо перестраивать embedding-индекс, не дублируя "
"основную бизнес-логику. Для поддержки будущих сценариев поиска по истории консультаций "
"и аудита источников все сообщения и RAG-запросы также сохраняются в основной базе данных.",
)
add_figure_placeholder(
document,
5,
"ER-диаграмма и логическая модель хранения данных LawBot",
"er-диаграммы",
)
add_heading(document, "3 РЕАЛИЗАЦИЯ ЧАТ-БОТА", level=1)
add_heading(document, "3.1 Используемые технологии и среда разработки", level=2)
add_table_with_caption(
document,
3,
"Основные технологии проекта",
["Технология", "Назначение"],
[
["Python 3.12/3.13", "Основной язык разработки сервисов"],
["aiogram 3.x [2]", "Telegram-бот и обработчики пользовательских состояний"],
["FastAPI [3]", "REST API для RAG-контура и индексации"],
["SQLAlchemy 2.x [4]", "ORM и общий слой доступа к базе данных"],
["PostgreSQL 16 [5]", "Хранение пользователей, истории консультаций и правовых документов"],
["ChromaDB [6]", "Векторная база данных для семантического поиска по чанкам"],
["Sentence Transformers [7]", "Локальная CPU-модель эмбеддингов deepvk/USER2-small [9]"],
["OpenRouter [8]", "OpenAI-compatible доступ к LLM openai/gpt-5.2 для классификации и генерации ответа"],
["Docker Compose [10]", "Контейнеризация и оркестрация сервисов"],
["Redis [11]", "Хранилище состояний FSM и служебных данных бота"],
],
)
add_paragraph(
document,
"Разработка выполнялась в виде многомодульного проекта. Для бота, API и парсера выбрана "
"единая рабочая директория с общим пакетом shared. Такое решение позволило использовать "
"одни и те же ORM-модели и репозитории как из Telegram-сервиса, так и из API и парсера, "
"не дублируя схему данных.",
)
add_heading(document, "3.2 Реализация парсера нормативных документов", level=2)
add_paragraph(
document,
"Парсер реализован как самостоятельный CLI-модуль parser. Он поддерживает команды "
"discover, fetch, normalize, ingest и run. Команда discover анализирует страницу "
"популярных правовых подборок на сайте КонсультантПлюс [1], формирует manifest и "
"определяет набор документов для загрузки. Команда fetch скачивает HTML-документы и "
"сохраняет их в raw-кеш. Команда normalize выделяет метаданные, статьи и внутреннюю "
"структуру документа, после чего сохраняет нормализованный JSON. Команда ingest "
"загружает полученные данные в PostgreSQL.",
)
add_paragraph(
document,
"Для каждого документа рассчитывается version_hash, что позволяет повторно запускать "
"пайплайн без создания лишних дублей. Документы сохраняются как сущности law_sources, "
"а статьи и их фрагменты – как law_chunks. В рамках MVP был успешно загружен набор из "
"13 правовых источников и 5304 чанков, что покрывает ключевые бытовые юридические "
"сценарии проекта.",
)
add_paragraph(
document,
"Сохранение исходного HTML и normalized JSON делает парсер пригодным для последующей "
"диагностики, повторной индексации и возможной смены механизма chunking без повторной "
"загрузки всего корпуса.",
)
add_heading(document, "3.3 Реализация Telegram-бота", level=2)
add_paragraph(
document,
"Пользовательский интерфейс реализован в сервисе tgbot на базе aiogram [2]. Бот "
"поддерживает главное меню, разделы «Задать вопрос», «Мои консультации», «Профиль» "
"и «Помощь», а также административные разделы. Логика диалога построена на FSM, "
"где отдельные состояния отвечают за выбор категории, ввод вопроса, ввод региона, "
"продолжение консультации и редактирование профиля. Для хранения состояния используется "
"Redis [11].",
)
add_paragraph(
document,
"В момент получения вопроса бот обращается к RAG API через отдельный модуль на базе "
"httpx. Запрос включает идентификатор пользователя, категорию вопроса, регион, тип "
"пользователя и при необходимости краткую историю предыдущих сообщений. После получения "
"ответа бот отображает его пользователю и сохраняет возможность открыть или продолжить "
"консультацию. Также реализованы суточные лимиты на число консультаций и ограничение "
"на количество пользовательских сообщений в одной ветке консультации.",
)
add_figure_placeholder(
document,
6,
"Главное меню Telegram-бота LawBot",
"скриншота интерфейса",
)
add_figure_placeholder(
document,
7,
"Пример ответа бота с найденными источниками",
"скриншота диалога",
)
add_heading(document, "3.4 Реализация RAG API и гибридного поиска", level=2)
add_paragraph(
document,
"RAG API реализован как отдельный FastAPI-сервис [3]. Он содержит эндпоинты /health, "
"/api/v1/index/rebuild, /api/v1/rag/search и /api/v1/rag/answer. Во время старта сервиса "
"инициализируется схема базы данных и запускается фоновая задача автоиндексации: если "
"коллекция ChromaDB пуста или рассинхронизирована с PostgreSQL, сервис автоматически "
"запускает rebuild индекса в фоне и детально логирует все этапы процесса.",
)
add_paragraph(
document,
"Алгоритм retrieval включает несколько фаз. Сначала LLM классифицирует вопрос и генерирует "
"набор search_queries. Затем для каждой поисковой фразы выполняется полнотекстовый поиск "
"по PostgreSQL и векторный поиск по ChromaDB. Результаты объединяются в общую таблицу "
"оценок, сортируются и отбираются по верхнему порогу top_k. Такой механизм позволяет "
"учитывать как точные совпадения ключевых слов, так и семантическую близость запроса "
"и текста нормы.",
)
add_paragraph(
document,
"Векторные представления создаются локальной CPU-моделью deepvk/USER2-small [9] через "
"библиотеку Sentence Transformers [7]. Для LLM-интеграции используется OpenRouter [8], "
"через который подключена модель openai/gpt-5.2 по OpenAI-compatible интерфейсу. "
"В промпте генерации ответа жёстко задано правило: использовать только те источники, "
"которые были возвращены retrieval-контуром.",
)
add_figure_placeholder(
document,
8,
"Фоновая индексация чанков и продовые логи RAG API",
"скриншота логов",
)
add_heading(document, "3.5 Надежность и эксплуатационные особенности", level=2)
add_paragraph(
document,
"Для повышения надежности проект контейнеризирован. В compose-конфигурации настроены "
"healthcheck-проверки для PostgreSQL, Redis и API, а также ротация логов через driver "
"json-file. Для embedding-модели настроен отдельный volume, благодаря которому модель "
"не скачивается повторно при каждом перезапуске. Для Telegram Bot API предусмотрена "
"поддержка proxy, параметризуемая через переменную окружения.",
)
add_paragraph(
document,
"Сервис API ведет расширенные продовые логи: фиксируются старт и завершение HTTP-запросов, "
"загрузка embedding-модели, шаги индексации, число найденных полнотекстовых и векторных "
"совпадений, а также параметры классификации и генерации ответа. Такая детализация "
"позволяет анализировать производительность и находить проблемные участки в retrieval-пайплайне.",
)
add_heading(document, "4 ТЕСТИРОВАНИЕ ЧАТ-БОТА", level=1)
add_heading(document, "4.1 Методика тестирования", level=2)
add_paragraph(
document,
"Тестирование проекта выполнялось как на уровне отдельных сервисов, так и на уровне "
"сквозного пользовательского сценария. Проверялись корректность парсинга документов, "
"загрузка данных в PostgreSQL, построение индекса в ChromaDB, маршрутизация запросов "
"через RAG API и реакция Telegram-бота на действия пользователя. Дополнительно были "
"проверены фоновые механизмы: автоиндексация при старте API, ротация логов и сохранение "
"кеша embedding-модели на volume.",
)
add_table_with_caption(
document,
4,
"Основные тестовые сценарии",
["", "Сценарий", "Ожидаемый результат", "Фактический результат"],
[
["1", "Команда /start", "Открывается главное меню бота", "Успешно"],
["2", "Новая консультация", "Пользователь проходит шаги категория -> вопрос -> регион -> ответ", "Успешно"],
["3", "RAG answer", "API возвращает ответ со ссылками на найденные источники", "Успешно"],
["4", "История консультаций", "Пользователь может открыть, продолжить и удалить консультацию", "Успешно"],
["5", "Перестроение индекса", "API индексирует чанки в ChromaDB без ручного вмешательства", "Успешно"],
["6", "Повторный запуск API", "Модель эмбеддингов берется из локального volume, скачивание не повторяется", "Успешно"],
],
)
add_heading(document, "4.2 Результаты тестирования", level=2)
add_paragraph(
document,
"В ходе функционального тестирования был выполнен полный пайплайн parser -> PostgreSQL -> "
"ChromaDB -> RAG API -> Telegram-бот. После запуска parser run в базе данных присутствовали "
"13 активных источников и 5304 чанка, что подтвердило корректность загрузки нормативного корпуса. "
"Затем был выполнен rebuild индекса и проверены поисковые запросы по темам трудового права, "
"защиты прав потребителей, жилья и договоров. Система корректно возвращала подборку правовых "
"норм и формировала итоговый ответ по найденным фрагментам.",
)
add_paragraph(
document,
"При пользовательском тестировании проверялись сценарии работы с главным меню, "
"оформление новой консультации, сохранение истории, изменение региона и типа пользователя, "
"а также повторное продолжение старой консультации. Все ключевые сценарии были отработаны "
"успешно, критических ошибок, приводящих к потере данных или невозможности получить ответ, "
"не обнаружено.",
)
add_figure_placeholder(
document,
9,
"Тестовый сценарий получения ответа по трудовому вопросу",
"скриншота тестирования",
)
add_figure_placeholder(
document,
10,
"Тестовый сценарий работы с историей консультаций",
"скриншота тестирования",
)
add_heading(document, "ЗАКЛЮЧЕНИЕ", level=1)
add_paragraph(
document,
"В результате выполнения курсовой работы был разработан Telegram-бот LawBot для "
"юридических консультаций по законодательству Российской Федерации. В ходе работы были "
"решены все поставленные задачи: проведен анализ предметной области, спроектированы "
"сценарии и архитектура системы, реализованы пользовательский бот, модуль подготовки "
"правового корпуса, общий слой работы с данными и RAG API, а также выполнено "
"тестирование полученного решения.",
)
add_paragraph(
document,
"Практическая значимость результата заключается в том, что пользователь получает "
"доступный интерфейс для первичной юридической консультации с опорой на локальный "
"корпус нормативных документов. В отличие от обычных генеративных ассистентов, "
"разработанная система контролирует происхождение ответа и ограничивает генерацию "
"только найденными источниками.",
)
add_paragraph(
document,
"В качестве направлений дальнейшего развития можно выделить расширение корпуса "
"документов, добавление автоматического обновления базы законов, подключение более "
"сильных reranking-моделей, поддержку загрузки пользовательских документов и развитие "
"аналитики по качеству retrieval и генерации ответов.",
)
add_heading(document, "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ", level=1)
references = [
"КонсультантПлюс. Популярные документы и кодексы [Электронный ресурс]. URL: https://www.consultant.ru/popular/ (дата обращения: 24.05.2026).",
"aiogram 3 documentation [Электронный ресурс]. URL: https://docs.aiogram.dev/ (дата обращения: 24.05.2026).",
"FastAPI Documentation [Электронный ресурс]. URL: https://fastapi.tiangolo.com/ (дата обращения: 24.05.2026).",
"SQLAlchemy 2.0 Documentation [Электронный ресурс]. URL: https://docs.sqlalchemy.org/ (дата обращения: 24.05.2026).",
"PostgreSQL Documentation [Электронный ресурс]. URL: https://www.postgresql.org/docs/ (дата обращения: 24.05.2026).",
"Chroma Documentation [Электронный ресурс]. URL: https://docs.trychroma.com/ (дата обращения: 24.05.2026).",
"Sentence Transformers Documentation [Электронный ресурс]. URL: https://www.sbert.net/ (дата обращения: 24.05.2026).",
"OpenRouter Documentation [Электронный ресурс]. URL: https://openrouter.ai/docs (дата обращения: 24.05.2026).",
"Модель deepvk/USER2-small на Hugging Face [Электронный ресурс]. URL: https://huggingface.co/deepvk/USER2-small (дата обращения: 24.05.2026).",
"Docker Compose file reference [Электронный ресурс]. URL: https://docs.docker.com/reference/compose-file/ (дата обращения: 24.05.2026).",
"Redis Documentation [Электронный ресурс]. URL: https://redis.io/docs/ (дата обращения: 24.05.2026).",
"PlantUML language reference guide [Электронный ресурс]. URL: https://plantuml.com/ru/guide (дата обращения: 24.05.2026).",
]
for index, item in enumerate(references, start=1):
paragraph = document.add_paragraph()
paragraph.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
paragraph.paragraph_format.first_line_indent = Cm(0)
paragraph.paragraph_format.left_indent = Cm(0)
run = paragraph.add_run(f"{index}. {item}")
run.font.name = "Times New Roman"
run._element.rPr.rFonts.set(qn("w:eastAsia"), "Times New Roman")
run.font.size = Pt(14)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
document.save(OUTPUT_PATH)
if __name__ == "__main__":
build_document()
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
from shared.models import (
Admin,
BaseModel,
Blacklist,
Consultation,
LawChunk,
LawSource,
Message,
RagQuery,
Setting,
User,
)
from shared.repositories import ORM
__all__ = [
"Admin",
"BaseModel",
"Blacklist",
"Consultation",
"LawChunk",
"LawSource",
"Message",
"ORM",
"RagQuery",
"Setting",
"User",
]
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine
from sqlalchemy.orm import sessionmaker
def create_async_engine(url: URL | str) -> AsyncEngine:
return _create_async_engine(url=url, pool_pre_ping=True, pool_recycle=3600)
def get_session_maker(engine: AsyncEngine) -> sessionmaker:
return sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+163
View File
@@ -0,0 +1,163 @@
from __future__ import annotations
from sqlalchemy import (
BIGINT,
BOOLEAN,
TIMESTAMP,
Column,
Date,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
VARCHAR,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import declarative_base, relationship
BaseModel = declarative_base()
class User(BaseModel):
__tablename__ = "users"
user_id = Column(BIGINT, primary_key=True)
username = Column(VARCHAR(33), nullable=True)
fullname = Column(VARCHAR(128), nullable=False)
country = Column(Text, nullable=False, default="Россия")
region = Column(Text, nullable=True)
user_type = Column(Text, nullable=False, default="physical_person")
register_date = Column(DateTime(timezone=True), nullable=False)
updated_at = Column(DateTime(timezone=True), nullable=False)
class Admin(BaseModel):
__tablename__ = "admins"
user_id = Column(BIGINT, primary_key=True)
username = Column(VARCHAR(33), nullable=True)
fullname = Column(VARCHAR(128), nullable=False)
class Blacklist(BaseModel):
__tablename__ = "blacklist"
user_id = Column(BIGINT, primary_key=True)
class Setting(BaseModel):
__tablename__ = "settings"
name = Column(String, primary_key=True)
value = Column(JSONB, nullable=True)
class Consultation(BaseModel):
__tablename__ = "consultations"
id = Column(BIGINT, primary_key=True, autoincrement=True)
user_id = Column(BIGINT, ForeignKey("users.user_id", ondelete="CASCADE"), nullable=False)
category = Column(Text, nullable=False)
title = Column(Text, nullable=True)
region = Column(Text, nullable=True)
status = Column(Text, nullable=False, default="active")
created_at = Column(TIMESTAMP(timezone=True), nullable=False)
updated_at = Column(TIMESTAMP(timezone=True), nullable=False)
messages = relationship("Message", back_populates="consultation")
rag_queries = relationship("RagQuery", back_populates="consultation")
class Message(BaseModel):
__tablename__ = "messages"
id = Column(BIGINT, primary_key=True, autoincrement=True)
consultation_id = Column(
BIGINT,
ForeignKey("consultations.id", ondelete="CASCADE"),
nullable=False,
)
role = Column(Text, nullable=False)
content = Column(Text, nullable=False)
sources_json = Column(JSONB, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), nullable=False)
consultation = relationship("Consultation", back_populates="messages")
rag_queries = relationship("RagQuery", back_populates="user_message")
class LawSource(BaseModel):
__tablename__ = "law_sources"
id = Column(BIGINT, primary_key=True, autoincrement=True)
title = Column(Text, nullable=False)
source_type = Column(Text, nullable=False)
jurisdiction = Column(Text, nullable=False, default="RU")
law_type = Column(Text, nullable=True)
document_number = Column(Text, nullable=True)
adoption_date = Column(Date, nullable=True)
publication_date = Column(Date, nullable=True)
effective_date = Column(Date, nullable=True)
source_url = Column(Text, nullable=False)
official_publication_number = Column(Text, nullable=True)
version_hash = Column(Text, nullable=False)
is_active = Column(BOOLEAN, nullable=False, default=True)
loaded_at = Column(TIMESTAMP(timezone=True), nullable=False)
chunks = relationship("LawChunk", back_populates="source")
__table_args__ = (
Index("law_sources_source_url_idx", "source_url"),
Index("law_sources_active_url_idx", "source_url", "is_active"),
)
class LawChunk(BaseModel):
__tablename__ = "law_chunks"
id = Column(BIGINT, primary_key=True, autoincrement=True)
source_id = Column(
BIGINT,
ForeignKey("law_sources.id", ondelete="CASCADE"),
nullable=False,
)
chunk_index = Column(Integer, nullable=False)
article_number = Column(Text, nullable=True)
article_title = Column(Text, nullable=True)
chunk_text = Column(Text, nullable=False)
chunk_metadata = Column("metadata", JSONB, nullable=False)
tsv = Column(TSVECTOR, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), nullable=False)
source = relationship("LawSource", back_populates="chunks")
__table_args__ = (
Index("law_chunks_source_id_idx", "source_id"),
Index("law_chunks_article_number_idx", "article_number"),
Index("law_chunks_tsv_idx", "tsv", postgresql_using="gin"),
)
class RagQuery(BaseModel):
__tablename__ = "rag_queries"
id = Column(BIGINT, primary_key=True, autoincrement=True)
consultation_id = Column(
BIGINT,
ForeignKey("consultations.id", ondelete="CASCADE"),
nullable=True,
)
user_message_id = Column(
BIGINT,
ForeignKey("messages.id", ondelete="CASCADE"),
nullable=True,
)
generated_queries = Column(JSONB, nullable=False)
retrieved_chunks = Column(JSONB, nullable=False)
created_at = Column(TIMESTAMP(timezone=True), nullable=False)
consultation = relationship("Consultation", back_populates="rag_queries")
user_message = relationship("Message", back_populates="rag_queries")
+723
View File
@@ -0,0 +1,723 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from decouple import config
from sqlalchemy import delete, func, select, text, update
from shared.engine import create_async_engine, get_session_maker
from shared.models import (
Admin,
BaseModel,
Blacklist,
Consultation,
LawChunk,
LawSource,
Message,
RagQuery,
Setting,
User,
)
from shared.types import JSONDict
def resolve_database_url() -> str:
database_url = config("DATABASE_URL", default=None)
if database_url:
return database_url
env_values = load_env_file_values(
Path(__file__).resolve().parent.parent / "postgres.env"
)
return (
f"postgresql+asyncpg://{config('POSTGRES_USER', default=env_values.get('POSTGRES_USER'))}:"
f"{config('POSTGRES_PASSWORD', default=env_values.get('POSTGRES_PASSWORD'))}"
f"@{config('POSTGRES_HOST', default=env_values.get('POSTGRES_HOST'))}:"
f"{config('POSTGRES_PORT', default=env_values.get('POSTGRES_PORT'))}/"
f"{config('POSTGRES_DB', default=env_values.get('POSTGRES_DB'))}"
)
def load_env_file_values(path: Path) -> dict[str, str]:
if not path.exists():
return {}
values: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip("'").strip('"')
return values
def normalize_law_types_arg(
law_types: list[str] | str | None,
) -> list[str] | None:
if law_types is None:
return None
if isinstance(law_types, str):
return [law_types]
normalized = [
item.strip()
for item in law_types
if isinstance(item, str) and item.strip()
]
return normalized or None
@dataclass(frozen=True)
class SourceUpsertDecision:
action: str
should_replace_chunks: bool
def classify_source_update(
existing_version_hash: str | None, new_version_hash: str
) -> SourceUpsertDecision:
if existing_version_hash is None:
return SourceUpsertDecision(action="create", should_replace_chunks=True)
if existing_version_hash == new_version_hash:
return SourceUpsertDecision(action="reuse", should_replace_chunks=False)
return SourceUpsertDecision(action="supersede", should_replace_chunks=True)
class ORM:
def __init__(self, database_url: str | None = None):
self.database_url = database_url or resolve_database_url()
self.async_engine = create_async_engine(url=self.database_url)
self.session_maker = get_session_maker(self.async_engine)
async def init_schema(self) -> None:
async with self.async_engine.begin() as conn:
await conn.run_sync(BaseModel.metadata.create_all)
await conn.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS country TEXT NOT NULL DEFAULT 'Россия'"
)
)
await conn.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS region TEXT")
)
await conn.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS user_type TEXT NOT NULL DEFAULT 'physical_person'"
)
)
await conn.execute(
text(
"ALTER TABLE users "
"ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"
)
)
await conn.execute(
text(
"UPDATE users "
"SET country = COALESCE(country, 'Россия'), "
"user_type = COALESCE(user_type, 'physical_person'), "
"updated_at = COALESCE(updated_at, register_date, now())"
)
)
async def proceed_schemas(self) -> None:
await self.init_schema()
async def close(self) -> None:
await self.async_engine.dispose()
async def get_active_law_source_by_url(self, source_url: str) -> LawSource | None:
async with self.session_maker() as session:
async with session.begin():
result = await session.scalars(
select(LawSource)
.where(
LawSource.source_url == source_url,
LawSource.is_active.is_(True),
)
.order_by(LawSource.loaded_at.desc())
)
return result.first()
async def upsert_law_source(self, payload: JSONDict) -> tuple[LawSource, bool]:
async with self.session_maker() as session:
async with session.begin():
existing = await session.scalars(
select(LawSource)
.where(
LawSource.source_url == payload["source_url"],
LawSource.is_active.is_(True),
)
.order_by(LawSource.loaded_at.desc())
)
active = existing.first()
decision = classify_source_update(
active.version_hash if active else None,
payload["version_hash"],
)
if decision.action == "reuse":
return active, False
if active is not None:
active.is_active = False
await session.flush()
source = LawSource(**payload)
session.add(source)
await session.flush()
return source, True
async def replace_law_chunks(
self, source_id: int, chunks: Iterable[JSONDict]
) -> int:
chunk_rows = list(chunks)
async with self.session_maker() as session:
async with session.begin():
await session.execute(
delete(LawChunk).where(LawChunk.source_id == source_id)
)
for row in chunk_rows:
session.add(
LawChunk(
source_id=source_id,
chunk_index=row["chunk_index"],
article_number=row.get("article_number"),
article_title=row.get("article_title"),
chunk_text=row["chunk_text"],
chunk_metadata=row["metadata"],
created_at=row.get(
"created_at", datetime.now(timezone.utc)
),
)
)
await session.flush()
await session.execute(
text(
"UPDATE law_chunks "
"SET tsv = to_tsvector('russian', chunk_text) "
"WHERE source_id = :source_id"
),
{"source_id": source_id},
)
return len(chunk_rows)
async def list_active_law_sources(
self, law_types: list[str] | None = None
) -> list[LawSource]:
law_types = normalize_law_types_arg(law_types)
async with self.session_maker() as session:
async with session.begin():
stmt = select(LawSource).where(LawSource.is_active.is_(True))
if law_types:
stmt = stmt.where(LawSource.law_type.in_(law_types))
result = await session.scalars(stmt.order_by(LawSource.title))
return result.all()
async def get_law_source(self, source_id: int) -> LawSource | None:
async with self.session_maker() as session:
async with session.begin():
return await session.get(LawSource, source_id)
async def get_chunks_by_source(self, source_id: int) -> list[LawChunk]:
async with self.session_maker() as session:
async with session.begin():
result = await session.scalars(
select(LawChunk)
.where(LawChunk.source_id == source_id)
.order_by(LawChunk.chunk_index)
)
return result.all()
async def get_chunks_count_by_source(self, source_id: int) -> int:
async with self.session_maker() as session:
async with session.begin():
result = await session.scalar(
select(func.count())
.select_from(LawChunk)
.where(LawChunk.source_id == source_id)
)
return int(result or 0)
async def list_chunks_for_indexing(
self,
source_ids: list[int] | None = None,
law_types: list[str] | None = None,
active_only: bool = True,
) -> list[JSONDict]:
law_types = normalize_law_types_arg(law_types)
async with self.session_maker() as session:
async with session.begin():
stmt = (
select(LawChunk, LawSource)
.join(LawSource, LawSource.id == LawChunk.source_id)
.order_by(LawSource.id, LawChunk.chunk_index)
)
if active_only:
stmt = stmt.where(LawSource.is_active.is_(True))
if source_ids:
stmt = stmt.where(LawSource.id.in_(source_ids))
if law_types:
stmt = stmt.where(LawSource.law_type.in_(law_types))
rows = await session.execute(stmt)
payloads: list[JSONDict] = []
for chunk, source in rows.all():
payloads.append(
{
"chunk_id": chunk.id,
"source_id": source.id,
"source_title": source.title,
"source_url": source.source_url,
"law_type": source.law_type,
"jurisdiction": source.jurisdiction,
"version_hash": source.version_hash,
"article_number": chunk.article_number,
"article_title": chunk.article_title,
"chunk_text": chunk.chunk_text,
"metadata": chunk.chunk_metadata,
}
)
return payloads
async def get_law_chunks_with_sources_by_ids(
self,
chunk_ids: list[int],
law_types: list[str] | None = None,
jurisdiction: str = "RU",
) -> list[JSONDict]:
law_types = normalize_law_types_arg(law_types)
if not chunk_ids:
return []
async with self.session_maker() as session:
async with session.begin():
stmt = (
select(LawChunk, LawSource)
.join(LawSource, LawSource.id == LawChunk.source_id)
.where(
LawChunk.id.in_(chunk_ids),
LawSource.is_active.is_(True),
LawSource.jurisdiction == jurisdiction,
)
)
if law_types:
stmt = stmt.where(LawSource.law_type.in_(law_types))
rows = await session.execute(stmt)
by_id: dict[int, JSONDict] = {}
for chunk, source in rows.all():
by_id[chunk.id] = {
"chunk_id": chunk.id,
"source_id": source.id,
"source_title": source.title,
"source_url": source.source_url,
"law_type": source.law_type,
"jurisdiction": source.jurisdiction,
"article_number": chunk.article_number,
"article_title": chunk.article_title,
"chunk_text": chunk.chunk_text,
"metadata": chunk.chunk_metadata,
}
return [by_id[chunk_id] for chunk_id in chunk_ids if chunk_id in by_id]
async def search_law_chunks_full_text(
self,
query: str,
law_types: list[str] | None = None,
jurisdiction: str = "RU",
limit: int = 20,
) -> list[JSONDict]:
law_types = normalize_law_types_arg(law_types)
async with self.session_maker() as session:
async with session.begin():
ts_query = func.plainto_tsquery("russian", query)
score = func.ts_rank(LawChunk.tsv, ts_query).label("score")
stmt = (
select(LawChunk, LawSource, score)
.join(LawSource, LawSource.id == LawChunk.source_id)
.where(
LawSource.jurisdiction == jurisdiction,
LawSource.is_active.is_(True),
LawChunk.tsv.op("@@")(ts_query),
)
.order_by(score.desc())
.limit(limit)
)
if law_types:
stmt = stmt.where(LawSource.law_type.in_(law_types))
rows = await session.execute(stmt)
payloads: list[JSONDict] = []
for chunk, source, result_score in rows.all():
payloads.append(
{
"chunk_id": chunk.id,
"source_id": source.id,
"source_title": source.title,
"source_url": source.source_url,
"law_type": source.law_type,
"jurisdiction": source.jurisdiction,
"article_number": chunk.article_number,
"article_title": chunk.article_title,
"chunk_text": chunk.chunk_text,
"metadata": chunk.chunk_metadata,
"score": float(result_score or 0.0),
}
)
return payloads
async def create_consultation(
self,
user_id: int,
category: str,
title: str | None = None,
region: str | None = None,
status: str = "active",
) -> Consultation:
now = datetime.now(timezone.utc)
async with self.session_maker() as session:
async with session.begin():
consultation = Consultation(
user_id=user_id,
category=category,
title=title,
region=region,
status=status,
created_at=now,
updated_at=now,
)
session.add(consultation)
await session.flush()
return consultation
async def create_message(
self,
consultation_id: int,
role: str,
content: str,
sources_json: Any | None = None,
) -> Message:
async with self.session_maker() as session:
async with session.begin():
message = Message(
consultation_id=consultation_id,
role=role,
content=content,
sources_json=sources_json,
created_at=datetime.now(timezone.utc),
)
session.add(message)
await session.execute(
update(Consultation)
.where(Consultation.id == consultation_id)
.values(updated_at=datetime.now(timezone.utc))
)
await session.flush()
return message
async def create_rag_query(
self,
consultation_id: int | None,
user_message_id: int | None,
generated_queries: list[str],
retrieved_chunks: list[JSONDict],
) -> RagQuery:
async with self.session_maker() as session:
async with session.begin():
rag_query = RagQuery(
consultation_id=consultation_id,
user_message_id=user_message_id,
generated_queries=generated_queries,
retrieved_chunks=retrieved_chunks,
created_at=datetime.now(timezone.utc),
)
session.add(rag_query)
await session.flush()
return rag_query
async def is_user_exists(self, user_id: int) -> bool:
async with self.session_maker() as session:
async with session.begin():
query = await session.execute(
select(User.user_id).where(User.user_id == user_id)
)
return query.one_or_none() is not None
async def create_user(
self, user_id: int, username: str | None, fullname: str, register_date: datetime
) -> int | None:
async with self.session_maker() as session:
async with session.begin():
existing = await session.get(User, user_id)
if existing is not None:
existing.username = username
existing.fullname = fullname
existing.updated_at = datetime.now(timezone.utc)
await session.flush()
return existing.user_id
user = User(
user_id=user_id,
username=username,
fullname=fullname,
country="Россия",
user_type="physical_person",
register_date=register_date,
updated_at=register_date,
)
session.add(user)
await session.flush()
return user.user_id
async def set_users_field(
self, user_id: int, field: str, value: int | str | bool
) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(
update(User)
.where(User.user_id == user_id)
.values(
{
getattr(User, field): value,
User.updated_at: datetime.now(timezone.utc),
}
)
)
async def set_user_region(self, user_id: int, region: str) -> None:
await self.set_users_field(user_id, "region", region)
async def set_user_type(self, user_id: int, user_type: str) -> None:
await self.set_users_field(user_id, "user_type", user_type)
async def get_user(self, user_id: int) -> User | None:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(
select(User).where(User.user_id == user_id)
)
return query.one_or_none()
async def get_all_users(self) -> list[User]:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(select(User))
return query.all()
async def get_users_count(self) -> int:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalar(select(func.count()).select_from(User))
return int(query or 0)
async def get_all_user_ids(self) -> list[int]:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(select(User.user_id))
return query.all()
async def delete_user(self, user_id: int) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(delete(User).where(User.user_id == user_id))
async def get_consultation(
self, consultation_id: int, user_id: int | None = None
) -> Consultation | None:
async with self.session_maker() as session:
async with session.begin():
stmt = select(Consultation).where(Consultation.id == consultation_id)
if user_id is not None:
stmt = stmt.where(Consultation.user_id == user_id)
query = await session.scalars(stmt)
return query.one_or_none()
async def list_user_consultations(
self, user_id: int, limit: int = 20
) -> list[Consultation]:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(
select(Consultation)
.where(Consultation.user_id == user_id)
.order_by(Consultation.updated_at.desc(), Consultation.id.desc())
.limit(limit)
)
return query.all()
async def get_consultation_messages(
self,
consultation_id: int,
limit: int | None = None,
) -> list[Message]:
async with self.session_maker() as session:
async with session.begin():
stmt = (
select(Message)
.where(Message.consultation_id == consultation_id)
.order_by(Message.created_at.asc(), Message.id.asc())
)
if limit is not None:
stmt = stmt.limit(limit)
query = await session.scalars(stmt)
return query.all()
async def delete_consultation(self, consultation_id: int, user_id: int) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(
delete(Consultation).where(
Consultation.id == consultation_id,
Consultation.user_id == user_id,
)
)
async def count_user_consultations_since(
self, user_id: int, since: datetime
) -> int:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalar(
select(func.count())
.select_from(Consultation)
.where(
Consultation.user_id == user_id,
Consultation.created_at >= since,
)
)
return int(query or 0)
async def count_user_messages_in_consultation(self, consultation_id: int) -> int:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalar(
select(func.count())
.select_from(Message)
.where(
Message.consultation_id == consultation_id,
Message.role == "user",
)
)
return int(query or 0)
async def is_admin_exists(self, user_id: int) -> bool:
async with self.session_maker() as session:
async with session.begin():
query = await session.execute(
select(Admin.user_id).where(Admin.user_id == user_id)
)
return query.one_or_none() is not None
async def create_admin(self, user_id: int, username: str, fullname: str) -> None:
async with self.session_maker() as session:
async with session.begin():
admin = Admin(user_id=user_id, username=username, fullname=fullname)
await session.merge(admin)
async def get_admin(self, user_id: int) -> Admin | None:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(
select(Admin).where(Admin.user_id == user_id)
)
return query.one_or_none()
async def get_all_admins(self) -> list[Admin]:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(select(Admin))
return query.all()
async def delete_admin(self, user_id: int) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(delete(Admin).where(Admin.user_id == user_id))
async def set_admin_field(
self, user_id: int, field: str, value: int | str | bool
) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(
update(Admin)
.where(Admin.user_id == user_id)
.values({getattr(Admin, field): value})
)
async def is_setting_exists(self, name: str) -> bool:
async with self.session_maker() as session:
async with session.begin():
query = await session.execute(
select(Setting).where(Setting.name == name)
)
return query.one_or_none() is not None
async def create_setting(self, name: str, value: Any) -> None:
async with self.session_maker() as session:
async with session.begin():
setting = Setting(name=name, value=value)
await session.merge(setting)
async def init_settings(self) -> None:
return None
async def get_setting_value(self, name: str) -> Any:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(
select(Setting.value).where(Setting.name == name)
)
return query.one_or_none()
async def update_setting_value(self, name: str, value: dict | list) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(
update(Setting)
.where(Setting.name == name)
.values({getattr(Setting, "value"): value})
)
async def is_blacklisted(self, user_id: int) -> bool:
async with self.session_maker() as session:
async with session.begin():
query = await session.execute(
select(Blacklist).where(Blacklist.user_id == user_id)
)
return query.one_or_none() is not None
async def create_blacklist(self, user_id: int) -> None:
async with self.session_maker() as session:
async with session.begin():
blacklist = Blacklist(user_id=user_id)
await session.merge(blacklist)
async def get_all_blacklist(self) -> list[int]:
async with self.session_maker() as session:
async with session.begin():
query = await session.scalars(
select(Blacklist.user_id).order_by(Blacklist.user_id)
)
return query.all()
async def delete_blacklist(self, user_id: int) -> None:
async with self.session_maker() as session:
async with session.begin():
await session.execute(
delete(Blacklist).where(Blacklist.user_id == user_id)
)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from enum import Enum
from typing import Any, TypedDict
class StrEnum(str, Enum):
def __str__(self) -> str:
return self.value
class SourceType(StrEnum):
CONSULTANT = "consultant_document"
class MessageRole(StrEnum):
USER = "user"
ASSISTANT = "assistant"
SYSTEM = "system"
class ParserChunkMetadata(TypedDict, total=False):
source_title: str
source_short_name: str
consultant_category: str
chapter_title: str | None
section_title: str | None
article_number: str | None
article_title: str | None
document_url: str
breadcrumb: list[str]
version_hash: str
JSONDict = dict[str, Any]