diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..238a0e5
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,142 @@
+name: Build, publish and deploy docker
+
+on:
+ push:
+ branches: [ 'main' ]
+ tags:
+ - 'v*'
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ build-and-push-image:
+ name: Build and push
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v2
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v4
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
+ type=raw,value=test,enable=true
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+
+ deploy-testing:
+ name: Deploy Testing
+ needs: build-and-push-image
+ runs-on: [ self-hosted, Linux, testing ]
+ environment:
+ name: Testing
+ env:
+ CONTAINER_NAME: com_profcomff_tgbot_mark_test
+ permissions:
+ packages: read
+ steps:
+ - name: Pull new version
+ run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test
+ - name: Migrate DB
+ run: |
+ docker run \
+ --rm \
+ --network=web \
+ --env DB_DSN='${{ secrets.DB_DSN }}' \
+ --env BOT_TOKEN='${{ secrets.BOT_TOKEN }}' \
+ --env BASE_URL='${{ vars.BASE_URL }}' \
+ --env WEBHOOK_PATH='${{ vars.WEBHOOK_PATH }}' \
+ --env QDRANT_API_KEY='${{ secrets.QDRANT_API_KEY }}' \
+ --env GIGA_KEY='${{ secrets.GIGA_KEY }}' \
+ --env HOST='${{ vars.HOST }}' \
+ --env PORT='${{ vars.PORT }}' \
+ --name ${{ env.CONTAINER_NAME }}_migration \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test \
+ alembic upgrade head
+ - name: Run new version
+ run: |
+ docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true
+ docker run \
+ --detach \
+ --network=web \
+ --restart on-failure:3 \
+ --env DB_DSN='${{ secrets.DB_DSN }}' \
+ --env BOT_TOKEN='${{ secrets.BOT_TOKEN }}' \
+ --env BASE_URL='${{ vars.BASE_URL }}' \
+ --env WEBHOOK_PATH='${{ vars.WEBHOOK_PATH }}' \
+ --env QDRANT_API_KEY='${{ secrets.QDRANT_API_KEY }}' \
+ --env GIGA_KEY='${{ secrets.GIGA_KEY }}' \
+ --env HOST='${{ vars.HOST }}' \
+ --env PORT='${{ vars.PORT }}' \
+ --name ${{ env.CONTAINER_NAME }} \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:test
+
+ deploy-production:
+ name: Deploy Production
+ needs: build-and-push-image
+ if: startsWith(github.ref, 'refs/tags/v')
+ runs-on: [ self-hosted, Linux, production ]
+ environment:
+ name: Production
+ env:
+ CONTAINER_NAME: com_profcomff_tgbot_mark
+ permissions:
+ packages: read
+ steps:
+ - name: Pull new version
+ run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+ - name: Migrate DB
+ run: |
+ docker run \
+ --rm \
+ --network=web \
+ --env DB_DSN='${{ secrets.DB_DSN }}' \
+ --env BOT_TOKEN='${{ secrets.BOT_TOKEN }}' \
+ --env BASE_URL='${{ vars.BASE_URL }}' \
+ --env WEBHOOK_PATH='${{ vars.WEBHOOK_PATH }}' \
+ --env QDRANT_API_KEY='${{ secrets.QDRANT_API_KEY }}' \
+ --env GIGA_KEY='${{ secrets.GIGA_KEY }}' \
+ --env HOST='${{ vars.HOST }}' \
+ --env PORT='${{ vars.PORT }}' \
+ --name ${{ env.CONTAINER_NAME }}_migration \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
+ alembic upgrade head
+ - name: Run new version
+ run: |
+ docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true
+ docker run \
+ --detach \
+ --network=web \
+ --restart always \
+ --env DB_DSN='${{ secrets.DB_DSN }}' \
+ --env BOT_TOKEN='${{ secrets.BOT_TOKEN }}' \
+ --env BASE_URL='${{ vars.BASE_URL }}' \
+ --env WEBHOOK_PATH='${{ vars.WEBHOOK_PATH }}' \
+ --env QDRANT_API_KEY='${{ secrets.QDRANT_API_KEY }}' \
+ --env GIGA_KEY='${{ secrets.GIGA_KEY }}' \
+ --env GIGA_KEY='${{ secrets.GIGA_KEY }}' \
+ --env HOST='${{ vars.HOST }}' \
+ --env PORT='${{ vars.PORT }}' \
+ --name ${{ env.CONTAINER_NAME }} \
+ ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
diff --git a/Dockerfile b/Dockerfile
index 799792f..9c33e06 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,17 +1,18 @@
FROM python:3.11.13-slim
-# Установка переменных окружения
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION} \
APP_NAME=answer \
APP_MODULE=${APP_NAME}.routes.base:app \
PYTHONPATH=/app \
- NLTK_DATA=/app/nltk_data
+ NLTK_DATA=/app/nltk_data \
+ CA_BUNDLE_URL=https://gu-st.ru/content/Other/doc/russiantrustedca.pem
-# Установка системных зависимостей и загрузка данных NLTK
WORKDIR /app
+
+# Установка системных зависимостей (включая curl для runtime)
RUN apt-get update && \
- apt-get install -y --no-install-recommends wget && \
+ apt-get install -y --no-install-recommends wget curl ca-certificates && \
mkdir -p /app/nltk_data && \
pip install nltk && \
python -c "import nltk; \
@@ -19,16 +20,17 @@ RUN apt-get update && \
nltk.download('punkt_tab', download_dir='/app/nltk_data'); \
nltk.download('punkt', download_dir='/app/nltk_data')" && \
apt-get remove -y wget && \
+ apt-get clean && \
rm -rf /var/lib/apt/lists/*
-# Копирование зависимостей и установка
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
-# Копирование исходного кода
COPY . .
-# Проверка структуры проекта (после COPY)
-RUN ls -lR /app
+# Entrypoint для загрузки сертификата
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
-CMD ["uvicorn", "answer.routes.base:app", "--host", "0.0.0.0", "--port", "8000"]
\ No newline at end of file
+ENTRYPOINT ["/entrypoint.sh"]
+CMD ["uvicorn", "answer.routes.base:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/README.md b/README.md
index 5770ebe..6979dcb 100644
--- a/README.md
+++ b/README.md
@@ -22,20 +22,14 @@
1. Перейдите в папку проекта
-2. Установите сертификаты Минцифры (для работы Гигачат API)
- ```console
- cd llm
- curl -o russian_trusted_root_ca.crt "https://gu-st.ru/content/Other/doc/russiantrustedca.pem"
- ```
-
-3. Создайте виртуальное окружение и активируйте его:
+2. Создайте виртуальное окружение и активируйте его:
```console
python3 -m venv venv
source ./venv/bin/activate # На MacOS и Linux
venv\Scripts\activate # На Windows
```
-4. Установите зависимости
+3. Установите зависимости
```console
pip install -r requirements.txt
```
@@ -43,42 +37,24 @@
python -m nltk.downloader punkt_tab
```
-5. Установите переменные окружения
+4. Установите переменные окружения
```console
# Ключ для доступа к бд
export QDRANT_API_KEY="qdrant_api_key"
-
- # Ключ для гигачат API
- export GIGA_KEY_PATH="gigakey.txt"
+
+ export SERVICE_ACCOUNT_ID="FROM YAGPT"
+
+ export KEY_ID="FROM YAGPT"
+
+ export PRIVATE_KEY="FROM YAGPT"
+
+ EXPORT BOT_TOKEN="FROM YAGPT"
```
-6. Запустите приложение
+5. Запустите приложение
```console
python -m answer
```
-
-## Запуск через Docker
-```console
-# Установка сертификатов
-cd llm
-curl -o russian_trusted_root_ca.crt "https://gu-st.ru/content/Other/doc/russiantrustedca.pem"
-
-# Сборка образа
-docker build -t my-fastapi-langchain .
-
-# Поднятие контейнера
-docker run -d \
- -p 127.0.0.1:8000:8000 \
- --name my-fastapi-langchain \
- -v "/Локальный/путь/до/chroma_db:/app/chroma_db" \
- -v "/Локальный/путь/до/gigakey.txt:/app/gigakey.txt:ro" \
- -e CHROMA_DIR="/app/chroma_db" \
- -e GIGA_KEY_PATH="/app/gigakey.txt" \
- -e APP_MODULE="answer.routes.base:app" \
- -e PYTHONPATH="/app" \
- my-fastapi-langchain
-```
-
## ENV-file description
- `DB_DSN=postgresql://postgres@localhost:5432/postgres` – Данные для подключения к БД
diff --git a/answer/__main__.py b/answer/__main__.py
index 810abf1..1ef6796 100644
--- a/answer/__main__.py
+++ b/answer/__main__.py
@@ -1,5 +1,25 @@
+import asyncio
+import logging
+
import uvicorn
+from aiogram import Bot, Dispatcher
+from aiogram.client.default import DefaultBotProperties
+from aiogram.enums import ParseMode
+from aiogram.types import BotCommand, BotCommandScopeDefault, Update
+from fastapi import Request
+
from answer.routes.base import app
+from answer.settings import Settings, get_settings
+
+
+settings: Settings = get_settings()
+logger = logging.getLogger(__name__)
if __name__ == '__main__':
- uvicorn.run(app)
+ try:
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+ logger.info("Starting FastAPI app with bot integration")
+
+ uvicorn.run(app, host=settings.HOST, port=settings.PORT, log_level="info")
+ except (KeyboardInterrupt, SystemExit):
+ logger.info("Application stopped")
diff --git a/answer/bot/__init__.py b/answer/bot/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/answer/bot/tg_bot/initialisation.py b/answer/bot/tg_bot/initialisation.py
new file mode 100644
index 0000000..ab40fbb
--- /dev/null
+++ b/answer/bot/tg_bot/initialisation.py
@@ -0,0 +1,58 @@
+import logging
+
+from aiogram import Bot, Dispatcher
+from aiogram.client.default import DefaultBotProperties
+from aiogram.enums import ParseMode
+from aiogram.fsm.storage.memory import MemoryStorage
+from aiogram.types import BotCommand, BotCommandScopeDefault
+
+from answer.handlers.ask_bot import router as router_ask_bot
+from answer.handlers.info import router as info_router
+from answer.handlers.start import start_router
+from answer.settings import Settings, get_settings
+
+
+settings: Settings = get_settings()
+logger = logging.getLogger(__name__)
+
+storage = MemoryStorage()
+
+bot: Bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+dp: Dispatcher = Dispatcher(storage=storage)
+
+dp.include_router(start_router)
+dp.include_router(info_router)
+dp.include_router(router_ask_bot)
+
+
+async def setup_bot():
+ logger.info("Setting up bot commands and webhook")
+
+ commands = [BotCommand(command='start', description='Старт')]
+ await bot.set_my_commands(commands, BotCommandScopeDefault())
+ await bot.set_webhook(f"{settings.BASE_URL}{settings.WEBHOOK_PATH}")
+
+ logger.info("Bot setup completed")
+
+
+async def bot_startup():
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+ logger.info("Starting FastAPI app with bot integration")
+
+ await setup_bot()
+
+ logger.info("Bot initialized and webhook set")
+ return bot, dp
+
+
+async def bot_shutdown():
+ if bot:
+ await bot.delete_webhook(drop_pending_updates=True)
+ await bot.session.close()
+
+ logger.info("Bot shutdown completed")
+
+
+def get_bot_objects(): # вот это здорово придумал конечно))
+ """Возвращает объекты бота и диспетчера"""
+ return bot, dp
diff --git a/answer/handlers/__init__.py b/answer/handlers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/answer/handlers/ask_bot.py b/answer/handlers/ask_bot.py
new file mode 100644
index 0000000..45cfb11
--- /dev/null
+++ b/answer/handlers/ask_bot.py
@@ -0,0 +1,313 @@
+import logging
+
+import httpx
+from aiogram import Router
+from aiogram.types import Message
+
+from answer.settings import Settings, get_settings
+from answer.utils.validation import (
+ get_safe_user_info,
+ validate_message,
+ validate_question,
+)
+
+
+logger = logging.getLogger(__name__)
+router = Router()
+settings: Settings = get_settings()
+
+
+async def call_internal_api(text: str, chat_id: str = "", generate_ai_response: bool = False):
+ """Вызов внутреннего API через HTTP-запрос к эндпоинту /greet"""
+ try:
+ request_data = {"text": text, "generate_ai_response": generate_ai_response, "user_chat_id": chat_id}
+
+ base_url = f"http://{settings.HOST}:{settings.PORT}"
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{base_url}/greet", json=request_data, headers={"Content-Type": "application/json"}, timeout=30.0
+ )
+
+ if response.status_code == 200:
+ return response.json()
+ else:
+ logger.error(f"HTTP ошибка {response.status_code}: {response.text}")
+ return None
+
+ except Exception as e:
+ logger.error(f"Ошибка HTTP-запроса к внутреннему API: {e}", exc_info=True)
+ return None
+
+
+async def save_conversation_api(user_chat_id: str, request: str, response: str, is_response_with_buttons: bool = False):
+ """Сохранение диалога через API"""
+ try:
+ base_url = f"http://{settings.HOST}:{settings.PORT}"
+ request_data = {
+ "user_chat_id": user_chat_id,
+ "request": request,
+ "response": response,
+ "is_response_with_buttons": is_response_with_buttons,
+ }
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{base_url}/conversations",
+ json=request_data,
+ headers={"Content-Type": "application/json"},
+ timeout=10.0,
+ )
+
+ if response.status_code == 200:
+ logger.info(f"Диалог успешно сохранен для пользователя {user_chat_id}")
+ return True
+ else:
+ logger.error(f"Ошибка сохранения диалога: {response.status_code} - {response.text}")
+ return False
+
+ except Exception as e:
+ logger.error(f"Ошибка HTTP-запроса сохранения диалога: {e}", exc_info=True)
+ return False
+
+
+@router.message()
+async def handle_any_message(message: Message):
+ """Обработчик любого вопроса"""
+ try:
+ if not message.text or message.text.startswith('/'):
+ return
+ message_validation = validate_message(message)
+ if not message_validation.is_valid:
+ safe_user = get_safe_user_info(message)
+ logger.warning(f"Невалидное сообщение от пользователя {safe_user['user_id']}: {message_validation.error}")
+ await message.answer(
+ f"❌ Ошибка в сообщении: {message_validation.error}\n\n" "Пожалуйста, попробуйте еще раз."
+ )
+ return
+
+ user_question = message_validation.data['text']
+
+ question_validation = validate_question(user_question)
+ if not question_validation.is_valid:
+ logger.warning(f"Невалидный вопрос от пользователя {message.from_user.id}: {question_validation.error}")
+ await message.answer(f"❌ {question_validation.error}\n\n" "Пожалуйста, переформулируйте ваш вопрос.")
+ return
+
+ validated_question = question_validation.data['question']
+
+ search_message = await message.answer("🔍 Ищу информацию и готовлю развернутый ответ...")
+
+ api_result = await call_internal_api(
+ text=validated_question, chat_id=str(message.chat.id), generate_ai_response=True
+ )
+
+ if not api_result or not api_result.get("ai_answer"):
+ await search_message.delete()
+
+ await message.answer(
+ "😕 К сожалению, не удалось получить ответ на ваш вопрос.\n"
+ "Попробуйте переформулировать вопрос и задать его снова."
+ )
+ return
+
+ answer = api_result["ai_answer"]
+
+ await save_conversation_api(str(message.chat.id), validated_question, answer, is_response_with_buttons=False)
+ await search_message.delete()
+ await message.answer(f"💡 Ответ:\n\n{answer}")
+
+ logger.info(f"Отправлен развернутый ответ пользователю {message.from_user.id}")
+
+ except Exception as e:
+ logger.error(f"Ошибка обработки сообщения: {e}", exc_info=True)
+ await message.answer("Произошла ошибка при обработке вашего вопроса. Попробуйте еще раз.")
+
+
+'''
+@router.callback_query(F.data == "response_buttons", StateFilter(QuestionState.waiting_for_response_type))
+async def handle_buttons_response(callback: CallbackQuery, state: FSMContext):
+ """Обработчик выбора ответа с кнопками"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ await state.clear()
+ return
+
+ data = await state.get_data()
+ user_question = data.get("user_question")
+ chat_id = data.get("chat_id")
+
+ await callback.message.edit_text("🔍 Ищу релевантные топики...")
+
+ api_result = await call_internal_api(text=user_question, chat_id="", generate_ai_response=False)
+
+ if not api_result or not api_result.get("results"):
+ no_results_keyboard = await get_no_results_keyboard()
+ await callback.message.edit_text(
+ "😕 К сожалению, не удалось найти релевантные топики по вашему вопросу.\n"
+ "Попробуйте переформулировать вопрос или выберите действие:",
+ reply_markup=no_results_keyboard.as_markup(),
+ )
+ await state.clear()
+ return
+
+ results = api_result["results"]
+ logger.info(f"Поиск релевантных топиков без контекста для вопроса: {user_question[:50]}...")
+
+ if not results:
+ no_results_keyboard = await get_no_results_keyboard()
+ await callback.message.edit_text(
+ "😕 К сожалению, не удалось найти релевантные топики по вашему вопросу.\n"
+ "Попробуйте переформулировать вопрос или выберите действие:",
+ reply_markup=no_results_keyboard.as_markup(),
+ )
+ await state.clear()
+ return
+
+ max_buttons = settings.MAX_BUTTONS
+ total_found = len(results)
+ current_page = 0
+
+ logger.info(
+ f"Найдено {total_found} результатов, показываем страницу {current_page + 1} (макс на странице: {max_buttons})"
+ )
+
+ await state.update_data(search_results=results, current_page=current_page, total_results=total_found)
+ await state.set_state(TopicState.showing_topics)
+
+ topics_keyboard = await get_topics_keyboard(results, page=current_page, total_results=total_found)
+
+ start_idx = current_page * max_buttons
+ end_idx = min(start_idx + max_buttons, total_found)
+ shown_count = end_idx - start_idx
+
+ message_text = f"🎯 Найдены релевантные топики по вашему вопросу:\n{user_question}\n\n"
+
+ if total_found > max_buttons:
+ total_pages = (total_found + max_buttons - 1) // max_buttons
+ message_text += f"Показаны результаты {start_idx + 1}-{end_idx} из {total_found} (страница {current_page + 1} из {total_pages}).\n\n"
+
+ message_text += "Выберите интересующий вас раздел:"
+
+ await callback.message.edit_text(message_text, reply_markup=topics_keyboard.as_markup())
+
+ page_topics = results[start_idx:end_idx]
+ response_text = f"Найдены топики (страница {current_page + 1}, показано {shown_count} из {total_found}): {', '.join([r['topic'] for r in page_topics])}"
+ await save_conversation_api(chat_id, user_question, response_text, is_response_with_buttons=True)
+
+ logger.info(f"Показаны релевантные топики пользователю {callback.from_user.id}")
+
+ except Exception as e:
+ logger.error(f"Ошибка получения релевантных топиков: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при поиске топиков.")
+ await state.clear()
+
+
+@router.callback_query(F.data.startswith("page_"), StateFilter(TopicState.showing_topics))
+async def handle_page_navigation(callback: CallbackQuery, state: FSMContext):
+ """Обработчик навигации по страницам с топиками"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ return
+
+ if callback.data == "page_info":
+ await callback.answer()
+ return
+
+ new_page = int(callback.data.split("_")[1])
+
+ data = await state.get_data()
+ search_results = data.get("search_results", [])
+ user_question = data.get("user_question", "")
+ total_results = data.get("total_results", len(search_results))
+
+ max_buttons = settings.MAX_BUTTONS
+ total_pages = (len(search_results) + max_buttons - 1) // max_buttons
+
+ if new_page < 0 or new_page >= total_pages:
+ await callback.answer("Неверный номер страницы")
+ return
+
+ await state.update_data(current_page=new_page)
+
+ topics_keyboard = await get_topics_keyboard(search_results, page=new_page, total_results=total_results)
+
+ start_idx = new_page * max_buttons
+ end_idx = min(start_idx + max_buttons, len(search_results))
+
+ message_text = f"🎯 Найдены релевантные топики по вашему вопросу:\n{user_question}\n\n"
+
+ if len(search_results) > max_buttons:
+ message_text += f"Показаны результаты {start_idx + 1}-{end_idx} из {len(search_results)} (страница {new_page + 1} из {total_pages}).\n\n"
+
+ message_text += "Выберите интересующий вас раздел:"
+
+ await callback.message.edit_text(message_text, reply_markup=topics_keyboard.as_markup())
+
+ await callback.answer()
+ logger.info(f"Пользователь {callback.from_user.id} переключился на страницу {new_page + 1}")
+
+ except Exception as e:
+ logger.error(f"Ошибка навигации по страницам: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при переключении страницы.")
+
+
+@router.callback_query(F.data.startswith("topic_"), StateFilter(TopicState.showing_topics))
+async def handle_topic_selection(callback: CallbackQuery, state: FSMContext):
+ """Обработчик выбора конкретного топика"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ return
+
+ topic_index = int(callback.data.split("_")[1])
+
+ data = await state.get_data()
+ search_results = data.get("search_results", [])
+ current_page = data.get("current_page", 0)
+
+ if topic_index >= len(search_results) or topic_index < 0:
+ await callback.answer("❌ Ошибка: неверный топик")
+ return
+
+ selected_topic = search_results[topic_index]
+ topic_name = selected_topic.get("topic", "Неизвестный топик")
+ full_text = selected_topic.get("full_text", "Информация недоступна")
+
+ if len(full_text) > 4000:
+ full_text = full_text[:3997] + "..."
+
+ response_text = f"📋 {topic_name}\n\n{full_text}"
+
+ if len(response_text) > 4096:
+ await callback.message.answer(f"📋 {topic_name}")
+ if len(full_text) > 4096:
+ full_text = full_text[:4093] + "..."
+ await callback.message.answer(full_text)
+ else:
+ await callback.message.answer(response_text)
+
+ menu_keyboard = await get_base_menu()
+ await callback.message.answer("❓ Могу еще чем-то помочь?", reply_markup=menu_keyboard.as_markup())
+
+ await callback.answer()
+ await state.clear()
+
+ logger.info(f"Пользователь {callback.from_user.id} выбрал топик #{topic_index + 1}: {topic_name[:50]}...")
+
+ except ValueError:
+ logger.error(f"Неверный формат callback_data: {callback.data}")
+ await callback.answer("❌ Ошибка обработки запроса")
+ except Exception as e:
+ logger.error(f"Ошибка выбора топика: {e}", exc_info=True)
+ await callback.answer("❌ Произошла ошибка при получении информации по топику.")
+ await state.clear()
+'''
\ No newline at end of file
diff --git a/answer/handlers/info.py b/answer/handlers/info.py
new file mode 100644
index 0000000..6e1a463
--- /dev/null
+++ b/answer/handlers/info.py
@@ -0,0 +1,78 @@
+import logging
+
+from aiogram import F, Router
+from aiogram.types import CallbackQuery
+
+from answer.handlers.keyboards import get_menu_from_help, get_menu_from_info
+from answer.utils.validation import validate_callback_query
+
+
+logger = logging.getLogger(__name__)
+
+router: Router = Router()
+
+
+@router.callback_query(F.data == "help")
+async def ask_for_help(callback: CallbackQuery):
+ """Обработчик запроса помощи от пользователя"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ return
+
+ help_text = """Раздел в разработке"""
+
+ # 🆘 Поддержка
+
+ # Если у вас возникли вопросы или проблемы, вы можете:
+
+ # 📧 Написать на почту: pochtazatichka@profcomff.com
+ # 💬 Обратиться в техническую поддержку через официальные каналы
+ # 🔧 Описать проблему максимально подробно для быстрого решения
+
+ # Мы постараемся ответить в кратчайшие сроки!
+
+ base_menu = await get_menu_from_help()
+ await callback.message.answer(text=help_text, reply_markup=base_menu.as_markup())
+ await callback.answer()
+ except Exception as e:
+ logger.error(f"Ошибка в ask_for_help: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при получении справки")
+
+
+@router.callback_query(F.data == "info")
+async def get_faq(callback: CallbackQuery):
+ """Обработчик FAQ"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ return
+
+ faq_text = """
+❓ Часто задаваемые вопросы (FAQ)
+
+Что умеет Марк?
+Марк может отвечать на вопросы, связанные с деятельностью Профкома и студенческой жизнью.
+
+Как задать вопрос?
+Напишите свой вопрос.
+
+Марк не понимает мой вопрос. Что делать?
+Попробуйте переформулировать вопрос или обратитесь в поддержку.
+
+Конфиденциальность
+Марк не сохраняет персональные данные пользователей без необходимости.
+
+Техническая поддержка
+Если у вас технические проблемы, воспользуйтесь кнопкой "Поддержка".
+ """
+ base_menu = await get_menu_from_info()
+ await callback.message.answer(text=faq_text, reply_markup=base_menu.as_markup())
+ await callback.answer()
+ except Exception as e:
+ logger.error(f"Ошибка в get_faq: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при получении FAQ")
diff --git a/answer/handlers/keyboards.py b/answer/handlers/keyboards.py
new file mode 100644
index 0000000..50b45fe
--- /dev/null
+++ b/answer/handlers/keyboards.py
@@ -0,0 +1,106 @@
+import logging
+
+from aiogram import Router
+from aiogram.utils.keyboard import InlineKeyboardBuilder
+
+from answer.settings import Settings, get_settings
+
+
+settings: Settings = get_settings()
+router: Router = Router()
+logger = logging.getLogger(__name__)
+
+
+async def get_base_menu() -> InlineKeyboardBuilder:
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="FAQ", callback_data="info")
+ builder.button(text="Поддержка", callback_data="help")
+ builder.adjust(1, 2)
+ return builder
+
+
+async def get_menu_from_info() -> InlineKeyboardBuilder:
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="Поддержка", callback_data="help")
+ builder.button(text="В главное меню", callback_data="back_to_menu")
+ builder.adjust(1, 2)
+ return builder
+
+
+async def get_menu_from_help() -> InlineKeyboardBuilder:
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="FAQ", callback_data="info")
+ builder.button(text="В главное меню", callback_data="back_to_menu")
+ builder.adjust(1, 2)
+ return builder
+
+
+async def get_ask_bot_keyboard() -> InlineKeyboardBuilder:
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="FAQ", callback_data="info")
+ builder.button(text="В главное меню", callback_data="back_to_menu")
+ builder.adjust(1, 2)
+ return builder
+
+
+async def get_response_type_keyboard() -> InlineKeyboardBuilder:
+ """Клавиатура выбора типа ответа"""
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="📄 Развернутый ответ", callback_data="response_detailed")
+ builder.button(text="🔗 Релевантные кнопки", callback_data="response_buttons")
+ builder.button(text="🔙 Назад", callback_data="back_to_menu")
+ builder.adjust(2, 1)
+ return builder
+
+
+async def get_topics_keyboard(topics_list: list, page: int = 0, total_results: int = None) -> InlineKeyboardBuilder:
+ """Клавиатура с релевантными топиками с поддержкой пагинации"""
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+
+ max_buttons = settings.MAX_BUTTONS
+ start_idx = page * max_buttons
+ end_idx = start_idx + max_buttons
+ page_topics = topics_list[start_idx:end_idx]
+
+ for i, result in enumerate(page_topics):
+ topic_name = result.get("topic", f"Топик {start_idx + i + 1}")
+ if len(topic_name) > 50:
+ topic_name = topic_name[:47] + "..."
+ builder.button(text=topic_name, callback_data=f"topic_{start_idx + i}")
+
+ nav_buttons = []
+
+ if page > 0:
+ nav_buttons.append(("⬅️", f"page_{page - 1}"))
+
+ total_pages = (len(topics_list) + max_buttons - 1) // max_buttons
+ if total_pages > 1:
+ start_idx = page * max_buttons
+ end_idx = min(start_idx + max_buttons, len(topics_list))
+ page_info = f"📄 {page + 1}/{total_pages}"
+ nav_buttons.append((page_info, "page_info"))
+
+ if end_idx < len(topics_list):
+ nav_buttons.append(("➡️", f"page_{page + 1}"))
+
+ for text, callback_data in nav_buttons:
+ builder.button(text=text, callback_data=callback_data)
+
+ builder.button(text="🔍 Новый поиск", callback_data="ask_llm")
+ builder.button(text="📋 Главное меню", callback_data="back_to_menu")
+
+ if nav_buttons:
+ builder.adjust(1, len(nav_buttons), 2)
+ else:
+ builder.adjust(1, 2)
+
+ return builder
+
+
+async def get_no_results_keyboard() -> InlineKeyboardBuilder:
+ """Клавиатура для случаев, когда результаты не найдены"""
+ builder: InlineKeyboardBuilder = InlineKeyboardBuilder()
+ builder.button(text="🔍 Попробовать еще раз", callback_data="ask_llm")
+ builder.button(text="📋 Главное меню", callback_data="back_to_menu")
+ builder.adjust(2)
+ return builder
diff --git a/answer/handlers/start.py b/answer/handlers/start.py
new file mode 100644
index 0000000..b21a29c
--- /dev/null
+++ b/answer/handlers/start.py
@@ -0,0 +1,112 @@
+import logging
+
+import httpx
+from aiogram import F, Router
+from aiogram.filters import CommandStart
+from aiogram.types import CallbackQuery, Message
+
+from answer.handlers.keyboards import get_base_menu
+from answer.settings import Settings, get_settings
+from answer.utils.validation import get_safe_user_info, validate_callback_query, validate_message
+
+
+logger = logging.getLogger(__name__)
+start_router = Router()
+settings: Settings = get_settings()
+
+
+async def get_or_create_user_api(chat_id: str):
+ """Получение или создание пользователя через API. Возвращает (user_data, is_new_user)"""
+ try:
+ base_url = f"http://{settings.HOST}:{settings.PORT}"
+
+ async with httpx.AsyncClient() as client:
+ get_response = await client.get(f"{base_url}/users/{chat_id}", timeout=10.0)
+
+ if get_response.status_code == 200:
+ user_data = get_response.json()
+ logger.info(f"Найден существующий пользователь: {chat_id}")
+ return user_data, False
+
+ elif get_response.status_code == 404:
+ request_data = {"chat_id": chat_id}
+ create_response = await client.post(
+ f"{base_url}/users", json=request_data, headers={"Content-Type": "application/json"}, timeout=10.0
+ )
+
+ if create_response.status_code == 200:
+ user_data = create_response.json()
+ logger.info(f"Создан новый пользователь: {chat_id}")
+ return user_data, True
+ else:
+ logger.error(
+ f"Ошибка создания пользователя: {create_response.status_code} - {create_response.text}"
+ )
+ return None, False
+ else:
+ logger.error(f"Ошибка получения пользователя: {get_response.status_code} - {get_response.text}")
+ return None, False
+
+ except Exception as e:
+ logger.error(f"Ошибка HTTP-запроса пользователя: {e}", exc_info=True)
+ return None, False
+
+
+@start_router.message(CommandStart())
+async def command_start_handler(message: Message) -> None:
+ try:
+ validation_result = validate_message(message)
+ if not validation_result.is_valid:
+ safe_user = get_safe_user_info(message)
+ logger.warning(
+ f"Невалидное /start сообщение от пользователя {safe_user['user_id']}: {validation_result.error}"
+ )
+ await message.answer("❌ Ошибка при обработке команды. Попробуйте еще раз.")
+ return
+
+ logger.info(f"Received /start command from user {message.from_user.id}")
+ chat_id = str(message.chat.id)
+ user_data, is_new_user = await get_or_create_user_api(chat_id)
+
+ if user_data:
+ if is_new_user:
+ message_text = (
+ f"Привет, {message.from_user.full_name}! Меня зовут Марк." + settings.BASE_DESCRIPTION
+ )
+ logger.info(f"Created new user with chat_id: {chat_id}")
+ else:
+ message_text = (
+ f"Привет, Марк помнит тебя, {message.from_user.full_name}!" + settings.BASE_DESCRIPTION
+ )
+ logger.info(f"User with chat_id {chat_id} already exists")
+ else:
+ message_text = f"Привет, {message.from_user.full_name}! Меня зовут Марк." + settings.BASE_DESCRIPTION
+ logger.warning(f"Failed to create/get user via API for chat_id: {chat_id}")
+
+ menu_builder = await get_base_menu()
+ await message.answer(text=message_text, reply_markup=menu_builder.as_markup())
+ logger.info("Response sent successfully")
+
+ except Exception as e:
+ logger.error(f"Error in start handler: {e}", exc_info=True)
+ await message.answer("Произошла ошибка. Попробуйте позже.\n Если проблема сохранится, обратитесь в поддержку")
+
+
+@start_router.callback_query(F.data == "back_to_menu")
+async def back_to_menu(callback: CallbackQuery):
+ """Возврат в главное меню"""
+ try:
+ validation_result = validate_callback_query(callback)
+ if not validation_result.is_valid:
+ logger.warning(f"Невалидный callback от пользователя {callback.from_user.id}: {validation_result.error}")
+ await callback.answer("❌ Ошибка валидации данных")
+ return
+
+ menu_builder = await get_base_menu()
+ message_text = f"🏠 Главное меню" + settings.BASE_DESCRIPTION
+ await callback.message.answer(text=message_text, reply_markup=menu_builder.as_markup())
+ await callback.answer()
+ logger.info("User returned to main menu")
+ except Exception as e:
+ logger.error(f"Error in back_to_menu handler: {e}", exc_info=True)
+ await callback.answer("Произошла ошибка при возврате в меню")
diff --git a/answer/handlers/states.py b/answer/handlers/states.py
new file mode 100644
index 0000000..4c6087d
--- /dev/null
+++ b/answer/handlers/states.py
@@ -0,0 +1,14 @@
+from aiogram.fsm.state import State, StatesGroup
+
+
+class QuestionState(StatesGroup):
+ """Состояния для обработки вопросов пользователя"""
+
+ waiting_for_question = State()
+ waiting_for_response_type = State()
+
+
+class TopicState(StatesGroup):
+ """Состояния для работы с релевантными топиками"""
+
+ showing_topics = State()
diff --git a/answer/migrations/README b/answer/migrations/README
new file mode 100644
index 0000000..98e4f9c
--- /dev/null
+++ b/answer/migrations/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/answer/migrations/env.py b/answer/migrations/env.py
new file mode 100644
index 0000000..6c4f58c
--- /dev/null
+++ b/answer/migrations/env.py
@@ -0,0 +1,66 @@
+from logging.config import fileConfig
+
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from answer.settings import Settings, get_settings
+from answer.models.db import BaseDbModel
+
+config = context.config
+settings: Settings = get_settings()
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = BaseDbModel.metadata # type: ignore
+
+
+def run_migrations_offline() -> None:
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = str(settings.db_dsn)
+ context.configure(
+ url=url,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ configuration = config.get_section(config.config_ini_section, {})
+ configuration["sqlalchemy.url"] = str(settings.DB_DSN)
+ connectable = engine_from_config(
+ configuration,
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+
+ with connectable.connect() as connection:
+ context.configure(connection=connection, target_metadata=target_metadata)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/answer/migrations/script.py.mako b/answer/migrations/script.py.mako
new file mode 100644
index 0000000..1101630
--- /dev/null
+++ b/answer/migrations/script.py.mako
@@ -0,0 +1,28 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ ${downgrades if downgrades else "pass"}
diff --git a/answer/migrations/versions/000d516c8c29_init.py b/answer/migrations/versions/54f6fdbddfce_init.py
similarity index 79%
rename from answer/migrations/versions/000d516c8c29_init.py
rename to answer/migrations/versions/54f6fdbddfce_init.py
index 5f6dc43..46c39a3 100644
--- a/answer/migrations/versions/000d516c8c29_init.py
+++ b/answer/migrations/versions/54f6fdbddfce_init.py
@@ -1,8 +1,8 @@
"""init
-Revision ID: 000d516c8c29
+Revision ID: 54f6fdbddfce
Revises:
-Create Date: 2025-09-08 21:33:09.976799
+Create Date: 2025-09-12 01:58:50.690957
"""
@@ -10,7 +10,7 @@
from alembic import op
-revision = '000d516c8c29'
+revision = '54f6fdbddfce'
down_revision = None
branch_labels = None
depends_on = None
@@ -33,6 +33,13 @@ def upgrade():
sa.Column('request', sa.String(), server_default='request_text', nullable=False, comment='Строка запроса'),
sa.Column('response', sa.String(), server_default='response_text', nullable=False, comment='Строка ответа'),
sa.Column('create_ts', sa.DateTime(), nullable=False, comment='Таймстемп создания пары request/response'),
+ sa.Column(
+ 'is_response_with_buttons',
+ sa.Boolean(),
+ server_default='false',
+ nullable=False,
+ comment='Генерировался ли в режиме возврата эндпоинтов (False - значит - чисто генерированный ai ответ)',
+ ),
sa.Column('is_deleted', sa.Boolean(), server_default='false', nullable=False, comment='Флаг софтделита'),
sa.ForeignKeyConstraint(
['user_id'],
diff --git a/answer/models/__init__.py b/answer/models/__init__.py
index 10365d4..230c16a 100644
--- a/answer/models/__init__.py
+++ b/answer/models/__init__.py
@@ -2,4 +2,4 @@
from answer.models.db import Conversation, User
-__all__ = ["Base", "User", "Conversation"]
+__all__ = ["Base", "BaseDbModel", "User", "Conversation"]
diff --git a/answer/models/base.py b/answer/models/base.py
index 3fc9b96..1b1c5d0 100644
--- a/answer/models/base.py
+++ b/answer/models/base.py
@@ -1,22 +1,90 @@
+from __future__ import annotations
+
import re
-from sqlalchemy.ext.declarative import as_declarative, declared_attr
+from sqlalchemy import not_
+from sqlalchemy.exc import NoResultFound
+from sqlalchemy.orm import Query, Session, as_declarative, declared_attr
+
+from answer.exceptions import ObjectNotFound, UpdateError
@as_declarative()
class Base:
"""Base class for all database entities"""
- @classmethod
@declared_attr
- def __tablename__(cls) -> str:
+ def __tablename__(cls) -> str: # pylint: disable=no-self-argument
"""Generate database table name automatically.
Convert CamelCase class name to snake_case db table name.
"""
return re.sub(r"(? str:
+ def __repr__(self):
attrs = []
for c in self.__table__.columns:
attrs.append(f"{c.name}={getattr(self, c.name)}")
- return "{}({})".format(self.__class__.__name__, ", ".join(attrs))
+ return "{}({})".format(c.__class__.__name__, ', '.join(attrs))
+
+
+class BaseDbModel(Base):
+ __abstract__ = True
+
+ @classmethod
+ def create(cls, *, session: Session, **kwargs) -> BaseDbModel:
+ obj = cls(**kwargs)
+ session.add(obj)
+ session.flush()
+ return obj
+
+ @classmethod
+ def query(cls, *, with_deleted: bool = False, session: Session) -> Query:
+ """Get all objects with soft deletes"""
+ objs = session.query(cls)
+ if not with_deleted and hasattr(cls, "is_deleted"):
+ objs = objs.filter(not_(cls.is_deleted))
+ return objs
+
+ @classmethod
+ def get(cls, id: int | str, *, with_deleted=False, session: Session) -> BaseDbModel:
+ """Get object with soft deletes"""
+ objs = session.query(cls)
+ if not with_deleted and hasattr(cls, "is_deleted"):
+ objs = objs.filter(not_(cls.is_deleted))
+ try:
+ if hasattr(cls, "uuid"):
+ return objs.filter(cls.uuid == id).one()
+ return objs.filter(cls.id == id).one()
+ except NoResultFound:
+ raise ObjectNotFound(cls, id)
+
+ @classmethod
+ def update(cls, id: int | str, *, session: Session, **kwargs) -> BaseDbModel:
+ obj = cls.get(id, session=session)
+
+ changed_fields = False
+ for field, new_value in kwargs.items():
+
+ old_value = getattr(obj, field)
+ if old_value != new_value:
+ changed_fields = True
+ break
+
+ if not changed_fields:
+ raise UpdateError(msg=f"No changes detected in fields")
+
+ for k, v in kwargs.items():
+ setattr(obj, k, v)
+
+ session.flush()
+ return obj
+
+ @classmethod
+ def delete(cls, id: int | str, *, session: Session) -> None:
+ """Soft delete object if possible, else hard delete"""
+ obj = cls.get(id, session=session)
+ if hasattr(obj, "is_deleted"):
+ obj.is_deleted = True
+ else:
+ session.delete(obj)
+ session.flush()
diff --git a/answer/models/db.py b/answer/models/db.py
index ba3aca5..af82dc0 100644
--- a/answer/models/db.py
+++ b/answer/models/db.py
@@ -13,11 +13,6 @@ class User(BaseDbModel):
id: Mapped[int] = mapped_column(primary_key=True, comment="Идентификатор пользователя")
chat_id: Mapped[str] = mapped_column(unique=True, comment="Тг айди чата с пользователем")
- conversations: Mapped[list["Conversation"]] = relationship(
- "Conversation",
- back_populates="user",
- primaryjoin="and_(User.id==Conversation.user_id, Conversation.is_deleted==False)",
- )
create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, comment="Таймстемп создания пользователя")
is_deleted: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false", default=False, comment="Флаг софтделита"
@@ -30,7 +25,6 @@ class Conversation(BaseDbModel):
"""
id: Mapped[int] = mapped_column(Integer, primary_key=True, comment="Идентификатор записи диалога")
- user: Mapped["User"] = relationship("User", back_populates="conversation")
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"))
request: Mapped[str] = mapped_column(
String, nullable=False, default="request_text", server_default='request_text', comment="Строка запроса"
@@ -39,6 +33,13 @@ class Conversation(BaseDbModel):
String, nullable=False, default="response_text", server_default='response_text', comment="Строка ответа"
)
create_ts: Mapped[datetime.datetime] = mapped_column(DateTime, comment="Таймстемп создания пары request/response")
+ is_response_with_buttons: Mapped[bool] = mapped_column(
+ Boolean,
+ nullable=False,
+ default=False,
+ server_default="false",
+ comment="Генерировался ли в режиме возврата эндпоинтов (False - значит - чисто генерированный ai ответ)",
+ )
is_deleted: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false", default=False, comment="Флаг софтделита"
)
diff --git a/answer/routes/base.py b/answer/routes/base.py
index 4b597f1..32a1790 100644
--- a/answer/routes/base.py
+++ b/answer/routes/base.py
@@ -1,34 +1,59 @@
+import datetime
+import logging
import sys
+
+
sys.path.append("../")
+from aiogram import Bot, Dispatcher
+from aiogram.types import Update
from fastapi import FastAPI, HTTPException, Request
-from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
-from pydantic import BaseModel
+from fastapi.responses import HTMLResponse
from fastapi_sqlalchemy import DBSessionMiddleware
-from answer import __version__
-from answer.settings import get_settings
-
+from langchain.retrievers import EnsembleRetriever
+from langchain_community.retrievers import BM25Retriever
from langchain_qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
+from sqlalchemy import and_, desc
+from sqlalchemy.engine import create_engine
+from sqlalchemy.orm import Session as DbSession
+from sqlalchemy.orm import sessionmaker
-from langchain_community.retrievers import BM25Retriever
+from answer import __version__
+from answer.bot.tg_bot.initialisation import bot_shutdown, bot_startup
+from answer.models.db import Conversation, User
+from answer.schemas.api_models import (
+ ConversationContextResponse,
+ CreateUserRequest,
+ SaveConversationRequest,
+ UserInput,
+ UserResponse,
+)
+from answer.schemas.db_models import StatusMessage
+from answer.services import get_search_service
+from answer.settings import get_settings
from llm.llm import get_answer
-
-from langchain.retrievers import EnsembleRetriever
-from search.nn import FilteredEnsembleRetriever
-
-from search.search import get_context, generate_keywords_dict, get_documents_from_qdrant
-from search.nn import init_embedder
-from search.preprocess import preprocess_stem
from search.filter import length_filter
+from search.nn import FilteredEnsembleRetriever, init_embedder
+from search.preprocess import preprocess_stem
+from search.search import generate_keywords_dict, get_context, get_documents_from_qdrant
+
settings = get_settings()
+search_service = get_search_service()
+logger = logging.getLogger(__name__)
+
+bot = None
+dp = None
+
+engine = create_engine(str(settings.DB_DSN), pool_pre_ping=True, pool_recycle=300)
+Session = sessionmaker(bind=engine)
+
app = FastAPI(
title='Ассистент',
description='-',
version=__version__,
-
root_path=settings.ROOT_PATH if __version__ != 'dev' else '',
docs_url=None if __version__ != 'dev' else '/docs',
redoc_url=None,
@@ -49,18 +74,55 @@
)
-class UserInput(BaseModel):
- text: str
- generate_ai_response: bool = False
+@app.post(settings.WEBHOOK_PATH)
+async def webhook_handler(request: Request):
+ """Обработчик webhook обновления от тг"""
+ global bot, dp
+
+ if not bot or not dp:
+ logger.error("Bot or dispatcher not initialized")
+ return {"status": "error", "message": "Bot not ready"}
+
+ try:
+ try:
+ update_data = await request.json()
+ except Exception as e:
+ logger.error(f"Invalid JSON in webhook: {e}")
+ return {"status": "error", "message": "Invalid JSON"}
+
+ if not update_data:
+ logger.error("Empty webhook data received")
+ return {"status": "error", "message": "Empty data"}
+
+ logger.info(f"Received webhook data with keys: {list(update_data.keys())}")
+
+ try:
+ update = Update(**update_data)
+ except Exception as e:
+ logger.error(f"Invalid Update object: {e}")
+ return {"status": "error", "message": "Invalid update format"}
+
+ logger.info(f"Created update object: {update}")
+
+ await dp.feed_update(bot=bot, update=update)
+ logger.info("Update processed successfully")
+
+ return {"status": "ok"}
+
+ except Exception as e:
+ logger.error(f"Error processing webhook: {e}", exc_info=True)
+ return {"status": "error", "message": str(e)}
+
-
@app.on_event("startup")
-def init_resources():
- with open(settings.GIGA_KEY_PATH, "r") as f:
- app.state.credentials = f.read().strip()
-
+async def init_resources():
+ global bot, dp
+
+ bot, dp = await bot_startup()
+ app.state.bot = bot
+
app.state.embedder = init_embedder()
-
+
app.state.qdrant_client = QdrantClient(
url="http://qdrant.profcomff.com:6333",
api_key=settings.QDRANT_API_KEY
@@ -73,11 +135,8 @@ def init_resources():
metadata_field="metadata"
)
-
app.state.bm25_retriever = BM25Retriever.from_documents(
- documents,
- preprocess_func=preprocess_stem,
- k=settings.retrivier_k
+ documents, preprocess_func=preprocess_stem, k=settings.retrivier_k
)
app.state.vector_store = QdrantVectorStore(
@@ -85,7 +144,7 @@ def init_resources():
collection_name=settings.collection_name,
embedding=app.state.embedder,
)
-
+
app.state.vector_retriever = app.state.vector_store.as_retriever(search_kwargs={"k": settings.retrivier_k})
app.state.ensemble_retriever = EnsembleRetriever(retrievers=[app.state.bm25_retriever, app.state.vector_retriever],
@@ -100,7 +159,26 @@ def init_resources():
vector_store=app.state.vector_store,
output_json_path="file/key_words_dict.json"
)
-
+
+
+ app_state_dict = {
+ "embedder": app.state.embedder,
+ "qdrant_client": app.state.qdrant_client,
+ "vector_store": app.state.vector_store,
+ "bm25_retriever": app.state.bm25_retriever,
+ "keywords_dict": app.state.keywords_dict,
+ }
+ search_service.set_app_state(app_state_dict)
+
+
+@app.on_event("shutdown")
+async def shutdown_resources():
+ global bot, dp
+ await bot_shutdown()
+ bot = None
+ dp = None
+
+
@app.post("/greet")
async def generate_response(user_input: UserInput):
if not user_input.text:
@@ -108,7 +186,6 @@ async def generate_response(user_input: UserInput):
if user_input.generate_ai_response:
ensemble_retriever = app.state.ensemble_retriever
-
else:
ensemble_retriever = app.state.filtered_ensemble_retriever
@@ -117,29 +194,164 @@ async def generate_response(user_input: UserInput):
key_words_dict=app.state.keywords_dict,
ensemble_retriever=ensemble_retriever,
vector_store=app.state.vector_store,
- ensemble_k = settings.ensemble_k,
+ ensemble_k=settings.ensemble_k,
verbose=True,
)
+ formatted_results = [
+ {
+ "topic": getattr(r, 'topic', ''),
+ "full_text": getattr(r, 'full_text', str(r)),
+ "metadata": getattr(r, 'metadata', {})
+ }
+ for r in results
+ ]
+
if user_input.generate_ai_response:
if length_filter(text=user_input.text, max_len=settings.max_length):
ai_answer = get_answer(
context=combined_text,
question=user_input.text,
- credentials=app.state.credentials,
settings=settings,
)
-
- return {"results": results, "ai_answer": ai_answer}
-
+
+ response = {"results": formatted_results}
+ if ai_answer:
+ response["ai_answer"] = ai_answer
+
+ return response
else:
- return {"results": [], "ai_answer": 'Ваш запрос слишком длинный :( Сделайте короче или используйте режим безGPT.'}
-
- if len(results) > 0:
- return {"results": results}
+ return {
+ "results": [],
+ "ai_answer": 'Ваш запрос слишком длинный :( Сделайте короче или используйте режим без GPT.'
+ }
+
+ if len(formatted_results) > 0:
+ return {"results": formatted_results}
else:
- return {"results": [], "ai_answer": 'Извините, я не понял Ваш запрос. Попробуйте использовать GPT версию.'}
+ return {
+ "results": [],
+ "ai_answer": 'Извините, я не понял Ваш запрос. Попробуйте использовать GPT версию.'
+ }
+
+
+@app.post("/users", response_model=UserResponse)
+async def create_user(user_request: CreateUserRequest):
+ """Создание нового пользователя"""
+ try:
+ with Session() as session:
+ existing_user = session.query(User).filter(User.chat_id == user_request.chat_id).first()
+ if existing_user:
+ return UserResponse(
+ id=existing_user.id,
+ chat_id=existing_user.chat_id,
+ create_ts=existing_user.create_ts,
+ is_deleted=existing_user.is_deleted,
+ )
+
+ new_user = User(
+ chat_id=user_request.chat_id, create_ts=datetime.datetime.now(datetime.timezone.utc), is_deleted=False
+ )
+ session.add(new_user)
+ session.commit()
+ session.refresh(new_user)
+
+ logger.info(f"Создан новый пользователь с chat_id: {user_request.chat_id}")
+
+ return UserResponse(
+ id=new_user.id, chat_id=new_user.chat_id, create_ts=new_user.create_ts, is_deleted=new_user.is_deleted
+ )
+
+ except Exception as e:
+ logger.error(f"Ошибка создания пользователя: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Ошибка создания пользователя")
+
+
+@app.get("/users/{chat_id}", response_model=UserResponse)
+async def get_user(chat_id: str):
+ """Получение пользователя по chat_id"""
+ try:
+ with Session() as session:
+ user = session.query(User).filter(User.chat_id == chat_id).one_or_none()
+ if user is None:
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+
+ return UserResponse(id=user.id, chat_id=user.chat_id, create_ts=user.create_ts, is_deleted=user.is_deleted)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Ошибка получения пользователя: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Ошибка получения пользователя")
+
+
+@app.get("/users/{chat_id}/context", response_model=ConversationContextResponse)
+async def get_conversation_context(chat_id: str):
+ """Получение контекста диалогов пользователя"""
+ try:
+ with Session() as session:
+ user = session.query(User).filter(User.chat_id == chat_id).one_or_none()
+ if user is None:
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+
+ conversations = (
+ session.query(Conversation)
+ .filter(and_(Conversation.user_id == user.id, Conversation.is_deleted == False))
+ .order_by(desc(Conversation.create_ts))
+ .limit(settings.CONTEXT_DEPTH)
+ .all()
+ )
+
+ if not conversations:
+ return ConversationContextResponse(context="", conversations_count=0)
+
+ conversations = list(reversed(conversations))
+ context_parts = []
+ for conv in conversations:
+ context_parts.append(f"Пользователь: {conv.request}")
+ context_parts.append(f"Ассистент: {conv.response}")
+
+ context_string = "\n".join(context_parts)
+
+ return ConversationContextResponse(context=context_string, conversations_count=len(conversations))
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Ошибка получения контекста диалогов: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Ошибка получения контекста диалогов")
+
+
+@app.post("/conversations")
+async def save_conversation(conversation_request: SaveConversationRequest):
+ """Сохранение диалога"""
+ try:
+ with Session() as session:
+ user = session.query(User).filter(User.chat_id == conversation_request.user_chat_id).one_or_none()
+ if user is None:
+ raise HTTPException(status_code=404, detail="Пользователь не найден")
+
+ conversation = Conversation(
+ user_id=user.id,
+ request=conversation_request.request,
+ response=conversation_request.response,
+ is_response_with_buttons=conversation_request.is_response_with_buttons,
+ create_ts=datetime.datetime.now(datetime.timezone.utc),
+ is_deleted=False,
+ )
+
+ session.add(conversation)
+ session.commit()
+
+ logger.info(f"Диалог сохранен для пользователя {conversation_request.user_chat_id}")
+
+ return {"status": "success", "message": "Диалог успешно сохранен"}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Ошибка сохранения диалога: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Ошибка сохранения диалога")
@app.get("/", response_class=HTMLResponse)
@@ -520,4 +732,4 @@ async def read_root():