first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
|
||||
volumes
|
||||
**/__pycache__
|
||||
+42
@@ -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-*
|
||||
@@ -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
@@ -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`;
|
||||
- отсутствие автоматических тестов.
|
||||
|
||||
Для новичка проект уже читаемый, но поддерживать его дальше будет проще после разделения больших модулей на более мелкие.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI RAG service package."""
|
||||
@@ -0,0 +1 @@
|
||||
"""API clients."""
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -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(),
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Prompt templates for the RAG API."""
|
||||
@@ -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. Пиши по-русски.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""FastAPI routers."""
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""Service layer for the RAG API."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)}."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
# Aiogram imports
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""CLI parser package for LawBot ingestion."""
|
||||
@@ -0,0 +1,5 @@
|
||||
from parser.cli import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+135
@@ -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)
|
||||
@@ -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/",
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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]
|
||||
@@ -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
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user