commit bfc22efe2457625e11b4c798ea9ad6884059cee1 Author: Dmitry Evdokimov Date: Mon May 25 01:12:43 2026 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..555776a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.venv +venv +__pycache__ +*.pyc +*.pyo + +volumes +**/__pycache__ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dcb115e --- /dev/null +++ b/.gitignore @@ -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-* diff --git a/README.md b/README.md new file mode 100644 index 0000000..b7e4d5d --- /dev/null +++ b/README.md @@ -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` diff --git a/SYSTEM_AUDIT.md b/SYSTEM_AUDIT.md new file mode 100644 index 0000000..490b598 --- /dev/null +++ b/SYSTEM_AUDIT.md @@ -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`; +- отсутствие автоматических тестов. + +Для новичка проект уже читаемый, но поддерживать его дальше будет проще после разделения больших модулей на более мелкие. diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..0ecb2cf --- /dev/null +++ b/api/.env.example @@ -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 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..8acb269 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..bd45c89 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +"""FastAPI RAG service package.""" diff --git a/api/clients/__init__.py b/api/clients/__init__.py new file mode 100644 index 0000000..60d4a88 --- /dev/null +++ b/api/clients/__init__.py @@ -0,0 +1 @@ +"""API clients.""" diff --git a/api/clients/chroma_store.py b/api/clients/chroma_store.py new file mode 100644 index 0000000..8e308fc --- /dev/null +++ b/api/clients/chroma_store.py @@ -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"], + ) diff --git a/api/clients/openrouter_client.py b/api/clients/openrouter_client.py new file mode 100644 index 0000000..0ba4809 --- /dev/null +++ b/api/clients/openrouter_client.py @@ -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, + ) diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..90afaf3 --- /dev/null +++ b/api/config.py @@ -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() diff --git a/api/deps.py b/api/deps.py new file mode 100644 index 0000000..59cea6e --- /dev/null +++ b/api/deps.py @@ -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(), + ) diff --git a/api/logging.py b/api/logging.py new file mode 100644 index 0000000..b0162e1 --- /dev/null +++ b/api/logging.py @@ -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 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..ebbca20 --- /dev/null +++ b/api/main.py @@ -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) diff --git a/api/prompts/__init__.py b/api/prompts/__init__.py new file mode 100644 index 0000000..d7a950c --- /dev/null +++ b/api/prompts/__init__.py @@ -0,0 +1 @@ +"""Prompt templates for the RAG API.""" diff --git a/api/prompts/rag_prompts.py b/api/prompts/rag_prompts.py new file mode 100644 index 0000000..73a4737 --- /dev/null +++ b/api/prompts/rag_prompts.py @@ -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. Пиши по-русски. +""" diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..d4378b3 --- /dev/null +++ b/api/requirements.txt @@ -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 diff --git a/api/routers/__init__.py b/api/routers/__init__.py new file mode 100644 index 0000000..c5cfac8 --- /dev/null +++ b/api/routers/__init__.py @@ -0,0 +1 @@ +"""FastAPI routers.""" diff --git a/api/routers/health.py b/api/routers/health.py new file mode 100644 index 0000000..bde6cec --- /dev/null +++ b/api/routers/health.py @@ -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") diff --git a/api/routers/indexing.py b/api/routers/indexing.py new file mode 100644 index 0000000..615b0b9 --- /dev/null +++ b/api/routers/indexing.py @@ -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) diff --git a/api/routers/rag.py b/api/routers/rag.py new file mode 100644 index 0000000..b747c39 --- /dev/null +++ b/api/routers/rag.py @@ -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, + ) diff --git a/api/schemas.py b/api/schemas.py new file mode 100644 index 0000000..696bcd5 --- /dev/null +++ b/api/schemas.py @@ -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 diff --git a/api/services/__init__.py b/api/services/__init__.py new file mode 100644 index 0000000..0f8e8cd --- /dev/null +++ b/api/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for the RAG API.""" diff --git a/api/services/indexing.py b/api/services/indexing.py new file mode 100644 index 0000000..10f2eba --- /dev/null +++ b/api/services/indexing.py @@ -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 diff --git a/api/services/legal_ai.py b/api/services/legal_ai.py new file mode 100644 index 0000000..c9a0111 --- /dev/null +++ b/api/services/legal_ai.py @@ -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 diff --git a/api/services/local_embeddings.py b/api/services/local_embeddings.py new file mode 100644 index 0000000..67a5619 --- /dev/null +++ b/api/services/local_embeddings.py @@ -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() diff --git a/api/services/retrieval.py b/api/services/retrieval.py new file mode 100644 index 0000000..5b76bdc --- /dev/null +++ b/api/services/retrieval.py @@ -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 diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..cd8d1b8 --- /dev/null +++ b/bot/.env.example @@ -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 diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..c7b2dc0 --- /dev/null +++ b/bot/Dockerfile @@ -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 diff --git a/bot/aiogram_run.py b/bot/aiogram_run.py new file mode 100644 index 0000000..3d7329d --- /dev/null +++ b/bot/aiogram_run.py @@ -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()) diff --git a/bot/create_bot.py b/bot/create_bot.py new file mode 100644 index 0000000..f172b88 --- /dev/null +++ b/bot/create_bot.py @@ -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() diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/admin/blacklist.py b/bot/handlers/admin/blacklist.py new file mode 100644 index 0000000..4e2da0e --- /dev/null +++ b/bot/handlers/admin/blacklist.py @@ -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"🚫 Черный список {offset + 1}/{max_offset}\n\n" + + for item in items[offset * 10 : (offset + 1) * 10]: + msg_text += f"✦ {item}\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"🚫 Черный список {offset + 1}/{max_offset}\n\n" + + for item in items[offset * 10 : (offset + 1) * 10]: + msg_text += f"✦ {item}\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) diff --git a/bot/handlers/admin/list_of_users.py b/bot/handlers/admin/list_of_users.py new file mode 100644 index 0000000..0c16ed5 --- /dev/null +++ b/bot/handlers/admin/list_of_users.py @@ -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) diff --git a/bot/handlers/admin/mailer.py b/bot/handlers/admin/mailer.py new file mode 100644 index 0000000..e2b1669 --- /dev/null +++ b/bot/handlers/admin/mailer.py @@ -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 = """✉️ Введите кнопки: + +
Отправьте ссылку(и) в формате: +[Текст кнопки + ссылка] +Пример: +[Переводчик + https://t.me/TransioBot] + +Чтобы добавить несколько кнопок в один ряд, пишите ссылки рядом с предыдущими. +Формат: +[Первый текст + первая ссылка][Второй текст + вторая ссылка] + +Чтобы добавить несколько кнопок в строчку, пишите новые ссылки с новой строки. +Формат: +[Первый текст + первая ссылка] +[Второй текст + вторая ссылка]
""" + + 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)}." + ) diff --git a/bot/handlers/admin/main.py b/bot/handlers/admin/main.py new file mode 100644 index 0000000..e626cc4 --- /dev/null +++ b/bot/handlers/admin/main.py @@ -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) diff --git a/bot/handlers/admin/management.py b/bot/handlers/admin/management.py new file mode 100644 index 0000000..992f7fc --- /dev/null +++ b/bot/handlers/admin/management.py @@ -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 = "👮‍♂️ Действующие администраторы\n" + + for admin in admins: + msg_text += f"✦ [{admin.user_id}]: {admin.username if admin.username else admin.fullname}\n" + + msg_text += f"\n🔽 Выберите действие:" + + 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) diff --git a/bot/handlers/admin/settings.py b/bot/handlers/admin/settings.py new file mode 100644 index 0000000..73497f6 --- /dev/null +++ b/bot/handlers/admin/settings.py @@ -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"""Текущее значение: +
{photo}
+ +⌨️ Отправьте фото для изменения:""" + + 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"""Текущее значение: +
{photo}
+ +⌨️ Отправьте фото для изменения:""" + + 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) diff --git a/bot/handlers/admin/statistic.py b/bot/handlers/admin/statistic.py new file mode 100644 index 0000000..9b38b1b --- /dev/null +++ b/bot/handlers/admin/statistic.py @@ -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"""📊 Статистика + +🔹 Кол-во пользователей в боте: {users_count:,} чел.""" + + await message.answer(text=msg_text) diff --git a/bot/handlers/client/__init__.py b/bot/handlers/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/client/main.py b/bot/handlers/client/main.py new file mode 100644 index 0000000..3a698a2 --- /dev/null +++ b/bot/handlers/client/main.py @@ -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(), + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..f035d60 --- /dev/null +++ b/bot/handlers/start.py @@ -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) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/keyboards/admin/mailer_kbs.py b/bot/keyboards/admin/mailer_kbs.py new file mode 100644 index 0000000..7f9bed4 --- /dev/null +++ b/bot/keyboards/admin/mailer_kbs.py @@ -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() diff --git a/bot/keyboards/admin/main_kbs.py b/bot/keyboards/admin/main_kbs.py new file mode 100644 index 0000000..5d99014 --- /dev/null +++ b/bot/keyboards/admin/main_kbs.py @@ -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() diff --git a/bot/keyboards/inline_keyboards.py b/bot/keyboards/inline_keyboards.py new file mode 100644 index 0000000..0175ad6 --- /dev/null +++ b/bot/keyboards/inline_keyboards.py @@ -0,0 +1,3 @@ +# Aiogram imports +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton diff --git a/bot/keyboards/reply_keyboards.py b/bot/keyboards/reply_keyboards.py new file mode 100644 index 0000000..3a0c643 --- /dev/null +++ b/bot/keyboards/reply_keyboards.py @@ -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) diff --git a/bot/middlewares/album.py b/bot/middlewares/album.py new file mode 100644 index 0000000..e316b71 --- /dev/null +++ b/bot/middlewares/album.py @@ -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) diff --git a/bot/middlewares/users_control.py b/bot/middlewares/users_control.py new file mode 100644 index 0000000..8758ce4 --- /dev/null +++ b/bot/middlewares/users_control.py @@ -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 diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..54e8465 --- /dev/null +++ b/bot/requirements.txt @@ -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 diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py new file mode 100644 index 0000000..23ea749 --- /dev/null +++ b/bot/states/admin_states.py @@ -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() diff --git a/bot/states/client_states.py b/bot/states/client_states.py new file mode 100644 index 0000000..8b44311 --- /dev/null +++ b/bot/states/client_states.py @@ -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() diff --git a/bot/templates/users.xlsx b/bot/templates/users.xlsx new file mode 100644 index 0000000..0c7d879 Binary files /dev/null and b/bot/templates/users.xlsx differ diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/utils/cfg_loader.py b/bot/utils/cfg_loader.py new file mode 100644 index 0000000..1e5243a --- /dev/null +++ b/bot/utils/cfg_loader.py @@ -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) diff --git a/bot/utils/rag_api.py b/bot/utils/rag_api.py new file mode 100644 index 0000000..b3db58c --- /dev/null +++ b/bot/utils/rag_api.py @@ -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 diff --git a/bot/utils/text_tools.py b/bot/utils/text_tools.py new file mode 100644 index 0000000..2358cdb --- /dev/null +++ b/bot/utils/text_tools.py @@ -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"\1", 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 diff --git a/bot/webhooks.py b/bot/webhooks.py new file mode 100644 index 0000000..05f4eb1 --- /dev/null +++ b/bot/webhooks.py @@ -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}) diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..fcc71e2 --- /dev/null +++ b/compose.yml @@ -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 diff --git a/mvp.md b/mvp.md new file mode 100644 index 0000000..84d8a3e --- /dev/null +++ b/mvp.md @@ -0,0 +1,1373 @@ +# MVP Telegram-бота: юридический ИИ-консультант по законам РФ + +Версия: 1.0 +География: только Российская Федерация +Основная функция: юридическая консультация через RAG по базе законов РФ +LLM-интеграция: OpenRouter или любой OpenAI-compatible API +Язык интерфейса: русский +Платформа: Telegram + +--- + +## 1. Что делаем в MVP + +MVP — это Telegram-бот, который: + +1. принимает юридический вопрос пользователя; +2. уточняет категорию права и регион РФ; +3. формирует поисковые запросы для RAG; +4. ищет релевантные нормы в локальной базе законов; +5. отвечает простым языком; +6. показывает найденные источники; +7. сохраняет консультацию в историю. + +Главная ценность: + +> Пользователь задает вопрос, бот находит применимые нормы закона РФ, объясняет их простыми словами и дает базовый план действий. + +--- + +## 2. Что НЕ входит в MVP + +В MVP НЕ делаем: + +- проверку PDF/DOCX-документов; +- генерацию исков; +- генерацию договоров; +- генерацию претензий; +- оплату и тарифы; +- личный кабинет юриста; +- передачу живому специалисту; +- голосовые сообщения; +- OCR; +- сложную админ-панель; +- судебную практику как обязательный источник; +- автоматическое полное обновление всей правовой базы. + +--- + +## 3. Главное меню Telegram-бота + +Минимальное меню: + +```text +⚖️ Задать вопрос +📚 Мои консультации +👤 Профиль +ℹ️ Помощь +``` + +--- + +## 4. Раздел «⚖️ Задать вопрос» + +### 4.1. Категории MVP + +После нажатия «Задать вопрос» бот показывает категории: + +```text +Выберите категорию вопроса: + +💼 Работа +🛒 Защита прав потребителей +🏠 Жилье / аренда +👪 Семья +💰 Долги / займы +📄 Договоры +⚖️ Суд / процесс +❓ Другое +``` + +Для MVP не нужно покрывать все право. Достаточно основных бытовых категорий. + +--- + +### 4.2. Базовый сценарий консультации + +```mermaid +flowchart TD + A[Пользователь нажал Задать вопрос] --> B[Выбор категории] + B --> C[Бот просит описать ситуацию] + C --> D[Пользователь пишет вопрос] + D --> E[Бот уточняет регион РФ] + E --> F[Пользователь указывает регион] + F --> G[Создание консультации в БД] + G --> H[LLM классифицирует вопрос] + H --> I[LLM формирует RAG-запросы] + I --> J[Hybrid search по базе законов] + J --> K[Rerank найденных чанков] + K --> L[LLM формирует ответ только по источникам] + L --> M[Бот отправляет ответ] + M --> N[Сохранение ответа и источников в БД] +``` + +--- + +### 4.3. Сообщения бота + +#### Старт + +```text +Здравствуйте. Я юридический ИИ-консультант по законам РФ. + +Я могу помочь: +— разобраться в ситуации; +— найти применимые нормы закона; +— объяснить их простыми словами; +— дать базовый план действий. + +Ответ носит информационный характер и не заменяет консультацию юриста. +``` + +#### Запрос описания + +```text +Опишите ситуацию одним сообщением. + +Постарайтесь указать: +— что произошло; +— когда произошло; +— с кем спор; +— чего вы хотите добиться. +``` + +#### Запрос региона + +```text +Укажите регион РФ, где произошла ситуация. + +Например: +Москва +Санкт-Петербург +Краснодарский край +Республика Татарстан +``` + +--- + +## 5. Формат ответа бота + +Бот должен отвечать в одном стабильном формате: + +```text +⚖️ Краткий вывод +... + +📌 Что говорит закон +... + +✅ Что можно сделать +1. ... +2. ... +3. ... + +⚠️ Риски и ограничения +... + +📚 Найденные источники +1. ... +2. ... +3. ... + +❗ Важно +Я ИИ-консультант, а не адвокат. Ответ носит информационный характер. Для суда, крупных сумм, уголовных дел и сложных споров лучше обратиться к юристу. +``` + +--- + +## 6. Главное правило RAG + +Строгое правило: + +> Если источник не найден в RAG — бот не имеет права ссылаться на него. + +Нельзя писать: + +```text +Возможно, применяется статья 450 ГК РФ. +``` + +Нужно писать только так: + +```text +Найденные источники: +— ГК РФ, статья 450 — основания изменения и расторжения договора +``` + +Если источник не найден: + +```text +Я не нашел в базе надежную норму по этому вопросу. Могу дать только общий комментарий без ссылки на конкретную статью. +``` + +--- + +## 7. Как RAG будет искать подходящие нормы + +### 7.1. Входные данные + +На вход RAG-пайплайна идут: + +```json +{ + "user_question": "Работодатель не выплатил зарплату за последний месяц. Что делать?", + "category": "labor", + "region": "Москва", + "jurisdiction": "RU", + "user_type": "physical_person" +} +``` + +--- + +### 7.2. Шаг 1 — классификация вопроса + +LLM получает вопрос и возвращает структурированный JSON: + +```json +{ + "legal_domain": "labor_law", + "jurisdiction": "RU", + "region": "Москва", + "issue_type": "salary_delay", + "needs_clarification": false, + "search_intents": [ + "срок выплаты заработной платы", + "ответственность работодателя за задержку зарплаты", + "компенсация за задержку заработной платы", + "жалоба в трудовую инспекцию" + ] +} +``` + +Если данных мало: + +```json +{ + "legal_domain": "consumer_protection", + "needs_clarification": true, + "questions": [ + "Товар был с недостатком или качественный?", + "Сколько дней прошло с покупки?", + "Покупка была онлайн или в магазине?" + ] +} +``` + +В этом случае бот сначала задает уточняющие вопросы, а не запускает финальный ответ. + +--- + +### 7.3. Шаг 2 — генерация поисковых запросов + +LLM формирует 3–5 поисковых запросов: + +```json +{ + "queries": [ + "ТК РФ срок выплаты заработной платы", + "ТК РФ задержка заработной платы компенсация", + "ответственность работодателя за невыплату зарплаты", + "куда жаловаться если не выплатили зарплату" + ], + "filters": { + "jurisdiction": "RU", + "law_type": ["labor"], + "is_active": true + } +} +``` + +Важно: запросы генерирует LLM, но поиск выполняется не по интернету, а по локальной базе законов. + +--- + +### 7.4. Шаг 3 — hybrid search + +Для юридического поиска нельзя полагаться только на embeddings. + +Нужно использовать гибридный поиск: + +1. `vector search` — поиск по смыслу; +2. `full-text / BM25` — поиск по точным словам; +3. `metadata filters` — фильтр по юрисдикции, отрасли права, актуальности; +4. `reranker` — финальная сортировка найденных чанков. + +Минимальная схема: + +```mermaid +flowchart LR + A[LLM search queries] --> B[Vector search] + A --> C[Full-text search] + B --> D[Merge results] + C --> D + D --> E[Metadata filters] + E --> F[Reranker] + F --> G[Top 3-7 chunks for answer] +``` + +--- + +### 7.5. Шаг 4 — rerank + +Поиск может вернуть 20–50 фрагментов. В ответ LLM нельзя отправлять всё. + +Рекомендуемый режим MVP: + +```text +vector_top_k = 20 +text_top_k = 20 +merged_top_k = 30 +rerank_top_k = 5 +``` + +В финальный промпт LLM отправляем только 3–7 самых релевантных чанков. + +--- + +### 7.6. Шаг 5 — генерация ответа + +LLM получает: + +```json +{ + "user_question": "...", + "category": "...", + "region": "...", + "retrieved_sources": [ + { + "source_title": "Трудовой кодекс РФ", + "article": "136", + "article_title": "Порядок, место и сроки выплаты заработной платы", + "text": "..." + }, + { + "source_title": "Трудовой кодекс РФ", + "article": "236", + "article_title": "Материальная ответственность работодателя за задержку выплаты заработной платы", + "text": "..." + } + ] +} +``` + +Системное правило для LLM: + +```text +Отвечай только на основании retrieved_sources. +Не придумывай статьи, номера законов и судебную практику. +Если источников недостаточно, прямо скажи, что данных в базе недостаточно. +``` + +--- + +## 8. Раздел «📚 Мои консультации» + +Пользователь видит список своих консультаций: + +```text +📚 Ваши консультации: + +1. Невыплата зарплаты — 23.05.2026 +2. Возврат товара — 22.05.2026 +3. Аренда квартиры — 21.05.2026 +``` + +Кнопки по консультации: + +```text +👁 Открыть +🔁 Продолжить +🗑 Удалить +``` + +### 8.1. Продолжить консультацию + +Если пользователь нажал «Продолжить», новые сообщения добавляются к старой консультации. + +В RAG отправляется: + +1. новый вопрос; +2. краткая история консультации; +3. старые найденные источники; +4. при необходимости — новый поиск. + +--- + +## 9. Раздел «👤 Профиль» + +Минимальный профиль: + +```text +👤 Профиль + +Страна: Россия +Регион: Москва +Тип пользователя: Физлицо +``` + +Кнопки: + +```text +🌍 Изменить регион +👔 Изменить тип пользователя +🗑 Удалить мои данные +``` + +Тип пользователя: + +```text +Физлицо +ИП +ООО +``` + +Для MVP тип пользователя нужен только как контекст для LLM. + +--- + +## 10. Раздел «ℹ️ Помощь» + +```text +ℹ️ Как пользоваться ботом + +1. Нажмите «Задать вопрос». +2. Выберите категорию. +3. Опишите ситуацию. +4. Укажите регион РФ. +5. Получите ответ с найденными источниками. + +Пример хорошего вопроса: +«Работодатель не выплатил зарплату за апрель. Работаю официально, Москва. Что делать?» + +Бот не заменяет юриста и не гарантирует результат спора. +``` + +--- + +## 11. Архитектура MVP + +### 11.1. Минимальная архитектура + +```mermaid +flowchart TD + TG[Telegram] --> BOT[bot-service aiogram] + BOT --> DB[(PostgreSQL)] + BOT --> RAG[RAG service] + RAG --> VDB[(pgvector / Qdrant)] + RAG --> DB + RAG --> LLM[OpenRouter / OpenAI-compatible API] + BOT --> LLM +``` + +Для самого простого MVP можно сделать монолит: + +```text +one Python app: +— aiogram bot +— FastAPI optional +— RAG logic +— PostgreSQL access +— OpenAI-compatible client +``` + +--- + +### 11.2. Рекомендуемый стек + +```text +Python 3.12+ +aiogram 3.x +SQLAlchemy 2.x +Alembic +PostgreSQL 16+ +pgvector или Qdrant +Redis optional +OpenAI Python SDK +OpenRouter или OpenAI-compatible LLM +Docker Compose +``` + +--- + +## 12. Хранилище + +### 12.1. Таблицы MVP + +```text +users +consultations +messages +law_sources +law_chunks +rag_queries +``` + +--- + +### 12.2. users + +```sql +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + telegram_id BIGINT UNIQUE NOT NULL, + username TEXT, + first_name TEXT, + country TEXT NOT NULL DEFAULT 'Россия', + region TEXT, + user_type TEXT NOT NULL DEFAULT 'physical_person', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +`user_type`: + +```text +physical_person +individual_entrepreneur +company +``` + +--- + +### 12.3. consultations + +```sql +CREATE TABLE consultations ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT NOT NULL, + title TEXT, + region TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +--- + +### 12.4. messages + +```sql +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, + consultation_id BIGINT NOT NULL REFERENCES consultations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + content TEXT NOT NULL, + sources_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +`role`: + +```text +user +assistant +system +``` + +--- + +### 12.5. law_sources + +```sql +CREATE TABLE law_sources ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + source_type TEXT NOT NULL, + jurisdiction TEXT NOT NULL DEFAULT 'RU', + law_type TEXT, + document_number TEXT, + adoption_date DATE, + publication_date DATE, + effective_date DATE, + source_url TEXT, + official_publication_number TEXT, + version_hash TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + loaded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Примеры `law_type`: + +```text +civil +labor +consumer +family +housing +tax +procedural +administrative +``` + +--- + +### 12.6. law_chunks + +Для pgvector: + +```sql +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE law_chunks ( + id BIGSERIAL PRIMARY KEY, + source_id BIGINT NOT NULL REFERENCES law_sources(id) ON DELETE CASCADE, + chunk_index INT NOT NULL, + article_number TEXT, + article_title TEXT, + chunk_text TEXT NOT NULL, + metadata JSONB, + embedding vector(1536), + tsv tsvector, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Индексы: + +```sql +CREATE INDEX law_chunks_source_id_idx ON law_chunks(source_id); +CREATE INDEX law_chunks_article_number_idx ON law_chunks(article_number); +CREATE INDEX law_chunks_tsv_idx ON law_chunks USING GIN(tsv); +CREATE INDEX law_chunks_embedding_idx ON law_chunks USING ivfflat (embedding vector_cosine_ops); +``` + +Размерность `embedding vector(1536)` нужно заменить под выбранную embedding-модель. + +--- + +### 12.7. rag_queries + +```sql +CREATE TABLE rag_queries ( + id BIGSERIAL PRIMARY KEY, + consultation_id BIGINT REFERENCES consultations(id) ON DELETE CASCADE, + user_message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE, + generated_queries JSONB NOT NULL, + retrieved_chunks JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +``` + +Нужно для отладки качества RAG. + +--- + +## 13. LLM-интеграция OpenRouter / OpenAI-compatible + +OpenRouter поддерживает OpenAI-compatible API-стиль. Поэтому в коде лучше сразу писать через `openai` Python SDK с настраиваемыми: + +```text +OPENAI_BASE_URL +OPENAI_API_KEY +LLM_MODEL +EMBEDDING_MODEL +``` + +### 13.1. Пример клиента + +```python +from openai import OpenAI +import os + +client = OpenAI( + api_key=os.getenv("OPENAI_API_KEY"), + base_url=os.getenv("OPENAI_BASE_URL", "https://openrouter.ai/api/v1"), +) + +response = client.chat.completions.create( + model=os.getenv("LLM_MODEL", "openai/gpt-4.1-mini"), + messages=[ + {"role": "system", "content": "Ты юридический ИИ-консультант по законам РФ."}, + {"role": "user", "content": "Работодатель не выплатил зарплату. Что делать?"} + ], + temperature=0.2, +) +``` + +Для OpenAI напрямую: + +```env +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_API_KEY=... +LLM_MODEL=gpt-4.1-mini +``` + +Для OpenRouter: + +```env +OPENAI_BASE_URL=https://openrouter.ai/api/v1 +OPENAI_API_KEY=... +LLM_MODEL=openai/gpt-4.1-mini +``` + +--- + +## 14. Structured output для классификации + +Классификацию лучше получать строго в JSON. + +### 14.1. Prompt: классификатор + +```text +Ты классификатор юридических вопросов по законам РФ. + +Верни только 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. Не давай юридический ответ на этом этапе. +``` + +### 14.2. Пример результата + +```json +{ + "legal_domain": "labor", + "issue_type": "salary_delay", + "jurisdiction": "RU", + "region": "Москва", + "needs_clarification": false, + "clarification_questions": [], + "search_queries": [ + "ТК РФ сроки выплаты заработной платы", + "ТК РФ задержка заработной платы компенсация", + "ответственность работодателя за задержку зарплаты" + ], + "filters": { + "law_type": ["labor"], + "jurisdiction": "RU", + "is_active": true + } +} +``` + +--- + +## 15. Prompt для финального ответа + +```text +Ты юридический ИИ-консультант по законам РФ. + +Твоя задача — ответить пользователю простым языком на основании найденных источников. + +Жесткие правила: +1. Используй только SOURCES. +2. Не придумывай статьи, номера законов, судебную практику и сроки. +3. Если источников недостаточно, прямо скажи об этом. +4. Не обещай победу в суде. +5. Не выдавай себя за адвоката. +6. Не помогай обходить закон. +7. В конце добавь дисклеймер. + +Формат ответа: + +⚖️ Краткий вывод +... + +📌 Что говорит закон +... + +✅ Что можно сделать +1. ... +2. ... +3. ... + +⚠️ Риски и ограничения +... + +📚 Найденные источники +1. [Название, статья, краткое описание] +2. ... + +❗ Важно +Ответ носит информационный характер и не заменяет консультацию юриста. +``` + +--- + +## 16. Источники законов РФ для базы + +### 16.1. Основной официальный источник + +Основной источник для MVP: + +```text +Официальный интернет-портал правовой информации +https://pravo.gov.ru/ +https://publication.pravo.gov.ru/ +``` + +Что важно: + +- это официальный контур опубликования правовых актов РФ; +- на `publication.pravo.gov.ru` есть раздел официального опубликования; +- есть раздел открытых данных; +- есть API-интерфейс; +- есть поиск по документам. + +Практически для MVP: + +```text +1. Использовать publication.pravo.gov.ru как первичный источник новых опубликованных актов. +2. Забрать нужные кодексы и федеральные законы. +3. Сохранять source_url, дату публикации, номер документа, hash версии. +4. Не пытаться на первом этапе спарсить всё право РФ. +``` + +Полезные URL: + +```text +https://publication.pravo.gov.ru/ +https://publication.pravo.gov.ru/OpenData +https://publication.pravo.gov.ru/help +``` + +--- + +### 16.2. Дополнительный официальный источник + +Дополнительный источник: + +```text +Портал Минюста России «Нормативные правовые акты в Российской Федерации» +https://pravo.minjust.ru/ +``` + +Что полезно: + +- федеральное законодательство; +- законодательство субъектов РФ; +- муниципальные акты; +- текущие и предыдущие редакции НПА. + +Полезные URL: + +```text +https://pravo.minjust.ru/ +https://pravo.minjust.ru/about_project +https://pravo.minjust.ru/about_project/setting_portal +``` + +--- + +### 16.3. Что парсить для MVP + +Не нужно сразу загружать все законы. + +Для MVP достаточно: + +```text +1. Конституция РФ +2. Гражданский кодекс РФ +3. Трудовой кодекс РФ +4. Семейный кодекс РФ +5. Жилищный кодекс РФ +6. Закон РФ «О защите прав потребителей» +7. Гражданский процессуальный кодекс РФ — базовые статьи +8. КоАП РФ — только базовые бытовые составы, если нужен административный блок +``` + +Для категорий MVP этого достаточно: + +| Категория в боте | Источники | +|---|---| +| Работа | ТК РФ | +| Потребители | Закон о защите прав потребителей, ГК РФ | +| Жилье / аренда | ГК РФ, ЖК РФ | +| Семья | СК РФ | +| Долги / займы | ГК РФ | +| Договоры | ГК РФ | +| Суд / процесс | ГПК РФ | + +--- + +### 16.4. Почему не стоит «парсить вообще всё» в MVP + +Полная база законодательства РФ — это сложно, потому что: + +- много редакций документов; +- есть федеральный, региональный и муниципальный уровни; +- есть акты, утратившие силу; +- у документов есть изменения и редакции; +- важна дата актуальности; +- часть пользовательских вопросов требует не только закон, но и практику применения. + +Для MVP правильнее сделать узкую, но качественную базу: + +```text +Кодексы + ключевые федеральные законы + метаданные + актуальность + хороший поиск. +``` + +--- + +## 17. Ingestion pipeline законов + +### 17.1. Схема загрузки + +```mermaid +flowchart TD + A[Источник: publication.pravo.gov.ru / pravo.minjust.ru] --> B[Fetcher] + B --> C[Raw storage] + C --> D[Text extractor / HTML parser] + D --> E[Normalizer] + E --> F[Split by articles] + F --> G[Chunker] + G --> H[Embeddings] + H --> I[(Vector DB / pgvector)] + E --> J[(PostgreSQL law_sources)] +``` + +--- + +### 17.2. Этапы + +#### 1. Fetcher + +Задача: + +```text +— скачать HTML/PDF/текст документа; +— сохранить оригинальный файл; +— сохранить URL; +— сохранить дату загрузки; +— сохранить hash. +``` + +#### 2. Raw storage + +Сохранять оригиналы обязательно: + +```text +/data/raw_laws/ + pravo/ + 2026-05-23/ + document_0001.html + document_0001.json +``` + +#### 3. Normalizer + +Приводит документ к единому виду: + +```json +{ + "title": "Трудовой кодекс Российской Федерации", + "document_number": "197-ФЗ", + "adoption_date": "2001-12-30", + "source_url": "...", + "text": "...", + "articles": [] +} +``` + +#### 4. Split by articles + +Кодексы нужно резать по статьям. + +Пример структуры: + +```json +{ + "article_number": "136", + "article_title": "Порядок, место и сроки выплаты заработной платы", + "text": "..." +} +``` + +#### 5. Chunker + +Если статья длинная, делить на чанки. + +Рекомендация: + +```text +chunk_size = 1000-1800 tokens +chunk_overlap = 100-200 tokens +``` + +Для коротких статей — один chunk = одна статья. + +#### 6. Embeddings + +Для каждого чанка: + +```text +embedding = embedding_model(chunk_text + article_title + source_title) +``` + +#### 7. Full-text index + +Дополнительно делать `tsvector` для PostgreSQL: + +```sql +UPDATE law_chunks +SET tsv = to_tsvector('russian', chunk_text); +``` + +--- + +## 18. Актуальность законов + +Для каждого источника хранить: + +```text +source_url +publication_date +effective_date +loaded_at +version_hash +is_active +``` + +Если документ обновился: + +```text +1. старая версия помечается is_active = false; +2. новая версия добавляется как новая запись; +3. чанки старой версии исключаются из поиска; +4. история консультаций сохраняет ссылки на версию, которая использовалась на момент ответа. +``` + +--- + +## 19. Retrieval API внутри проекта + +Минимальная функция: + +```python +async def retrieve_law_chunks( + queries: list[str], + law_types: list[str], + jurisdiction: str = "RU", + top_k: int = 5, +) -> list[LawChunk]: + ... +``` + +Внутри: + +```text +1. Создать embedding для каждого query. +2. Выполнить vector search. +3. Выполнить full-text search. +4. Слить результаты. +5. Убрать дубли. +6. Отфильтровать is_active = true. +7. Прогнать reranker. +8. Вернуть top_k чанков. +``` + +--- + +## 20. Пример SQL для hybrid search + +### 20.1. Full-text search + +```sql +SELECT + lc.id, + lc.chunk_text, + lc.article_number, + lc.article_title, + ls.title AS source_title, + ts_rank(lc.tsv, plainto_tsquery('russian', :query)) AS score +FROM law_chunks lc +JOIN law_sources ls ON ls.id = lc.source_id +WHERE + ls.jurisdiction = 'RU' + AND ls.is_active = true + AND lc.tsv @@ plainto_tsquery('russian', :query) +ORDER BY score DESC +LIMIT 20; +``` + +### 20.2. Vector search + +```sql +SELECT + lc.id, + lc.chunk_text, + lc.article_number, + lc.article_title, + ls.title AS source_title, + 1 - (lc.embedding <=> :query_embedding) AS score +FROM law_chunks lc +JOIN law_sources ls ON ls.id = lc.source_id +WHERE + ls.jurisdiction = 'RU' + AND ls.is_active = true +ORDER BY lc.embedding <=> :query_embedding +LIMIT 20; +``` + +--- + +## 21. FSM-состояния aiogram + +Минимальные состояния: + +```python +class AskQuestion(StatesGroup): + choosing_category = State() + waiting_question = State() + waiting_region = State() + processing = State() +``` + +Сценарий: + +```text +/start + -> main_menu + +Задать вопрос + -> choosing_category + +Выбрана категория + -> waiting_question + +Пользователь написал вопрос + -> waiting_region + +Пользователь указал регион + -> processing + -> RAG + -> answer + -> main_menu +``` + +--- + +## 22. Ограничения MVP + +### 22.1. Лимиты + +Чтобы не сжечь бюджет LLM: + +```text +Free MVP: +— 5 консультаций в день на пользователя +— максимум 5 сообщений в одной консультации +— максимум 7 retrieved chunks в одном ответе +— timeout LLM-запроса 60 секунд +``` + +### 22.2. Rate limit + +Ключ: + +```text +rate_limit:{telegram_id}:{date} +``` + +Хранить можно в Redis или PostgreSQL. + +--- + +## 23. Логирование + +Логировать: + +```text +telegram_id +consultation_id +category +region +user_question +generated_queries +retrieved_chunk_ids +llm_model +tokens_input +tokens_output +latency_ms +errors +``` + +Не логировать лишние персональные данные без необходимости. + +--- + +## 24. Safety rules + +Бот не должен: + +```text +— гарантировать победу в суде; +— обещать точный исход дела; +— советовать подделывать документы; +— помогать скрывать доходы; +— помогать обходить закон; +— составлять фиктивные схемы; +— выдавать себя за адвоката; +— давать категоричные инструкции по уголовным делам без рекомендации юриста. +``` + +В сложных случаях: + +```text +По этому вопросу лучше обратиться к юристу/адвокату, потому что ошибка может повлечь серьезные последствия. +``` + +--- + +## 25. Минимальный docker-compose + +```yaml +services: + bot: + build: . + env_file: + - .env + depends_on: + - postgres + restart: unless-stopped + + postgres: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_DB: legal_bot + POSTGRES_USER: legal_bot + POSTGRES_PASSWORD: legal_bot_password + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + restart: unless-stopped + +volumes: + postgres_data: +``` + +--- + +## 26. .env + +```env +BOT_TOKEN= + +DATABASE_URL=postgresql+asyncpg://legal_bot:legal_bot_password@postgres:5432/legal_bot + +OPENAI_BASE_URL=https://openrouter.ai/api/v1 +OPENAI_API_KEY= +LLM_MODEL=openai/gpt-4.1-mini + +EMBEDDING_PROVIDER=openai +EMBEDDING_MODEL=text-embedding-3-small + +DAILY_CONSULTATION_LIMIT=5 +``` + +--- + +## 27. MVP roadmap + +### Этап 1 — Telegram shell + +```text +— /start +— главное меню +— выбор категории +— ввод вопроса +— ввод региона +— сохранение пользователя +``` + +### Этап 2 — база законов + +```text +— загрузить 5–8 основных источников +— распарсить по статьям +— сохранить law_sources +— сохранить law_chunks +— построить embeddings +— построить full-text index +``` + +### Этап 3 — RAG + +```text +— классификатор вопроса +— генератор search_queries +— hybrid search +— rerank +— финальный ответ с источниками +``` + +### Этап 4 — история + +```text +— список консультаций +— открыть консультацию +— продолжить консультацию +— удалить консультацию +``` + +### Этап 5 — защита от мусора + +```text +— лимиты +— логирование +— обработка ошибок LLM +— fallback если RAG ничего не нашел +— дисклеймер +``` + +--- + +## 28. Критерии готовности MVP + +MVP можно считать готовым, если: + +```text +1. Пользователь может задать вопрос. +2. Бот уточняет категорию и регион. +3. LLM генерирует поисковые запросы. +4. RAG возвращает реальные статьи из базы. +5. Ответ содержит только найденные источники. +6. История консультации сохраняется. +7. Пользователь может открыть старую консультацию. +8. Если источников нет, бот честно говорит об этом. +``` + +--- + +## 29. Короткое ТЗ в одну фразу + +Сделать Telegram-бота на Python/aiogram, который консультирует пользователей по законам РФ: принимает вопрос, уточняет категорию и регион, через OpenAI-compatible LLM формирует RAG-запросы, ищет по локальной базе законов РФ через hybrid search, отвечает простым языком с найденными источниками и сохраняет консультации в историю. + +--- + +## 30. Главный принцип разработки + +Не делать «ChatGPT в Telegram». + +Делать юридический workflow: + +```text +Категория → Вопрос → Регион → Классификация → RAG → Ответ с источниками → История +``` + +Самое важное качество MVP: + +```text +Лучше меньше категорий и источников, но чтобы бот не выдумывал статьи и всегда показывал, откуда взял правовую норму. +``` diff --git a/parser/__init__.py b/parser/__init__.py new file mode 100644 index 0000000..d5a863f --- /dev/null +++ b/parser/__init__.py @@ -0,0 +1 @@ +"""CLI parser package for LawBot ingestion.""" diff --git a/parser/__main__.py b/parser/__main__.py new file mode 100644 index 0000000..da281a5 --- /dev/null +++ b/parser/__main__.py @@ -0,0 +1,5 @@ +from parser.cli import main + + +if __name__ == "__main__": + main() diff --git a/parser/cli.py b/parser/cli.py new file mode 100644 index 0000000..fb2757e --- /dev/null +++ b/parser/cli.py @@ -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) diff --git a/parser/config.py b/parser/config.py new file mode 100644 index 0000000..a1f649c --- /dev/null +++ b/parser/config.py @@ -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/", + }, +] diff --git a/parser/discovery.py b/parser/discovery.py new file mode 100644 index 0000000..eba82a6 --- /dev/null +++ b/parser/discovery.py @@ -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 diff --git a/parser/fetcher.py b/parser/fetcher.py new file mode 100644 index 0000000..44202fe --- /dev/null +++ b/parser/fetcher.py @@ -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 diff --git a/parser/ingest.py b/parser/ingest.py new file mode 100644 index 0000000..b274547 --- /dev/null +++ b/parser/ingest.py @@ -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 diff --git a/parser/normalizer.py b/parser/normalizer.py new file mode 100644 index 0000000..69b1b63 --- /dev/null +++ b/parser/normalizer.py @@ -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) diff --git a/parser/utils.py b/parser/utils.py new file mode 100644 index 0000000..513e2ed --- /dev/null +++ b/parser/utils.py @@ -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] diff --git a/postgres.env.example b/postgres.env.example new file mode 100644 index 0000000..d2250f8 --- /dev/null +++ b/postgres.env.example @@ -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 diff --git a/protocol/generate_protocol_docx.py b/protocol/generate_protocol_docx.py new file mode 100644 index 0000000..d45b66f --- /dev/null +++ b/protocol/generate_protocol_docx.py @@ -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() diff --git a/protocol/task/SWE_lab2.docx b/protocol/task/SWE_lab2.docx new file mode 100644 index 0000000..510f875 Binary files /dev/null and b/protocol/task/SWE_lab2.docx differ diff --git a/protocol/task/SWE_lab3.docx b/protocol/task/SWE_lab3.docx new file mode 100644 index 0000000..73726b4 Binary files /dev/null and b/protocol/task/SWE_lab3.docx differ diff --git a/protocol/task/ОФОРМЛЕНИЕ.docx b/protocol/task/ОФОРМЛЕНИЕ.docx new file mode 100644 index 0000000..0e56113 Binary files /dev/null and b/protocol/task/ОФОРМЛЕНИЕ.docx differ diff --git a/protocol/task/пример оформления курсовой.doc b/protocol/task/пример оформления курсовой.doc new file mode 100644 index 0000000..09f8326 Binary files /dev/null and b/protocol/task/пример оформления курсовой.doc differ diff --git a/protocol/Протокол_курсовой_работы_LawBot.docx b/protocol/Протокол_курсовой_работы_LawBot.docx new file mode 100644 index 0000000..84e6629 Binary files /dev/null and b/protocol/Протокол_курсовой_работы_LawBot.docx differ diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..93ef2b9 --- /dev/null +++ b/shared/__init__.py @@ -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", +] diff --git a/shared/engine.py b/shared/engine.py new file mode 100644 index 0000000..339b773 --- /dev/null +++ b/shared/engine.py @@ -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) diff --git a/shared/models.py b/shared/models.py new file mode 100644 index 0000000..94a74c0 --- /dev/null +++ b/shared/models.py @@ -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") diff --git a/shared/repositories.py b/shared/repositories.py new file mode 100644 index 0000000..88f0a7d --- /dev/null +++ b/shared/repositories.py @@ -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) + ) diff --git a/shared/types.py b/shared/types.py new file mode 100644 index 0000000..6f7d2e3 --- /dev/null +++ b/shared/types.py @@ -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]