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