add gpt model health check (#21)

This commit is contained in:
Dmitry Afanasyev 2023-09-29 20:30:58 +03:00 committed by GitHub
parent 52df4d338f
commit 42f5191042
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 228 additions and 69 deletions

View File

@ -2,6 +2,8 @@ from fastapi import APIRouter, Request
from starlette import status from starlette import status
from starlette.responses import Response from starlette.responses import Response
from constants import INVALID_GPT_MODEL_MESSAGE
from core.utils import ChatGptService
from settings.config import settings from settings.config import settings
router = APIRouter() router = APIRouter()
@ -9,7 +11,7 @@ router = APIRouter()
@router.post( @router.post(
f"/{settings.TELEGRAM_API_TOKEN}", f"/{settings.TELEGRAM_API_TOKEN}",
name="system:process_bot_updates", name="bot:process_bot_updates",
response_class=Response, response_class=Response,
status_code=status.HTTP_202_ACCEPTED, status_code=status.HTTP_202_ACCEPTED,
summary="process bot updates", summary="process bot updates",
@ -17,3 +19,26 @@ router = APIRouter()
) )
async def process_bot_updates(request: Request) -> None: async def process_bot_updates(request: Request) -> None:
await request.app.state.queue.put_updates_on_queue(request) await request.app.state.queue.put_updates_on_queue(request)
@router.get(
"/bot-healthcheck",
name="bot:gpt_healthcheck",
response_class=Response,
summary="bot healthcheck",
responses={
status.HTTP_500_INTERNAL_SERVER_ERROR: {"description": "Request to chat gpt not success"},
status.HTTP_200_OK: {"description": "Successful Response"},
},
)
async def gpt_healthcheck(response: Response) -> Response:
chat_gpt_service = ChatGptService(chat_gpt_model=settings.GPT_MODEL)
data = chat_gpt_service.build_request_data('Привет!')
try:
gpt_response = await chat_gpt_service.do_request(data)
if gpt_response.text == INVALID_GPT_MODEL_MESSAGE or response.status_code != status.HTTP_200_OK:
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
except Exception:
response.status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
return Response(status_code=response.status_code, content=None)

View File

@ -4,6 +4,7 @@ AUDIO_SEGMENT_DURATION = 120 * 1000
API_PREFIX = "/api" API_PREFIX = "/api"
CHAT_GPT_BASE_URI = "/backend-api/v2/conversation" CHAT_GPT_BASE_URI = "/backend-api/v2/conversation"
INVALID_GPT_MODEL_MESSAGE = "Invalid request model"
class BotStagesEnum(StrEnum): class BotStagesEnum(StrEnum):

View File

@ -1,18 +1,14 @@
import asyncio import asyncio
import random
import tempfile import tempfile
from urllib.parse import urljoin from urllib.parse import urljoin
from uuid import uuid4
import httpx
from httpx import AsyncClient, AsyncHTTPTransport
from loguru import logger from loguru import logger
from telegram import InlineKeyboardMarkup, Update from telegram import InlineKeyboardMarkup, Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from constants import CHAT_GPT_BASE_URI, BotEntryPoints from constants import BotEntryPoints
from core.keyboards import main_keyboard from core.keyboards import main_keyboard
from core.utils import SpeechToTextService from core.utils import ChatGptService, SpeechToTextService
from settings.config import settings from settings.config import settings
@ -38,7 +34,7 @@ async def about_bot(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
return None return None
await update.effective_message.reply_text( await update.effective_message.reply_text(
f"Бот использует бесплатную модель {settings.GPT_MODEL} для ответов на вопросы. " f"Бот использует бесплатную модель {settings.GPT_MODEL} для ответов на вопросы. "
f"Принимает запросы на разных языках.\n\nБот так же умеет переводить русские голосовые сообщения в текст. " f"\nПринимает запросы на разных языках.\n\nБот так же умеет переводить русские голосовые сообщения в текст. "
f"Просто пришлите голосовуху и получите поток сознания в виде текста, но без знаков препинания", f"Просто пришлите голосовуху и получите поток сознания в виде текста, но без знаков препинания",
parse_mode="Markdown", parse_mode="Markdown",
) )
@ -71,38 +67,9 @@ async def ask_question(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
await update.message.reply_text("Пожалуйста подождите, ответ в среднем занимает 10-15 секунд") await update.message.reply_text("Пожалуйста подождите, ответ в среднем занимает 10-15 секунд")
chat_gpt_request = { chat_gpt_service = ChatGptService(chat_gpt_model=settings.GPT_MODEL)
"conversation_id": str(uuid4()), answer = await chat_gpt_service.request_to_chatgpt(question=update.message.text)
"action": "_ask", await update.message.reply_text(answer)
"model": settings.GPT_MODEL,
"jailbreak": "default",
"meta": {
"id": random.randint(10**18, 10**19 - 1), # noqa: S311
"content": {
"conversation": [],
"internet_access": False,
"content_type": "text",
"parts": [{"content": update.message.text, "role": "user"}],
},
},
}
transport = AsyncHTTPTransport(retries=3)
async with AsyncClient(base_url=settings.GPT_BASE_HOST, transport=transport, timeout=50) as client:
try:
response = await client.post(CHAT_GPT_BASE_URI, json=chat_gpt_request, timeout=50)
status = response.status_code
if status != httpx.codes.OK:
logger.info(f"got response status: {status} from chat api", data=chat_gpt_request)
await update.message.reply_text(
"Что-то пошло не так, попробуйте еще раз или обратитесь к администратору"
)
return
await update.message.reply_text(response.text)
except Exception as error:
logger.error("error get data from chat api", error=error)
await update.message.reply_text("Вообще всё сломалось :(")
async def voice_recognize(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def voice_recognize(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:

View File

@ -1,10 +1,14 @@
import os import os
import random
import subprocess # noqa import subprocess # noqa
from concurrent.futures.thread import ThreadPoolExecutor from concurrent.futures.thread import ThreadPoolExecutor
from datetime import datetime, timedelta from datetime import datetime, timedelta
from functools import lru_cache, wraps from functools import lru_cache, wraps
from typing import Any from typing import Any
from uuid import uuid4
import httpx
from httpx import AsyncClient, AsyncHTTPTransport, Response
from loguru import logger from loguru import logger
from pydub import AudioSegment from pydub import AudioSegment
from speech_recognition import ( from speech_recognition import (
@ -13,7 +17,12 @@ from speech_recognition import (
UnknownValueError as SpeechRecognizerError, UnknownValueError as SpeechRecognizerError,
) )
from constants import AUDIO_SEGMENT_DURATION from constants import (
AUDIO_SEGMENT_DURATION,
CHAT_GPT_BASE_URI,
INVALID_GPT_MODEL_MESSAGE,
)
from settings.config import settings
def timed_cache(**timedelta_kwargs: Any) -> Any: def timed_cache(**timedelta_kwargs: Any) -> Any:
@ -103,3 +112,49 @@ class SpeechToTextService:
os.remove(tmp_filename) os.remove(tmp_filename)
logger.error("error recognizing text with google", error=error) logger.error("error recognizing text with google", error=error)
raise error raise error
class ChatGptService:
def __init__(self, chat_gpt_model: str) -> None:
self.chat_gpt_model = chat_gpt_model
async def request_to_chatgpt(self, question: str | None) -> str:
question = question or "Привет!"
chat_gpt_request = self.build_request_data(question)
try:
response = await self.do_request(chat_gpt_request)
status = response.status_code
if response.text == INVALID_GPT_MODEL_MESSAGE:
message = f"{INVALID_GPT_MODEL_MESSAGE}: {settings.GPT_MODEL}"
logger.info(message, data=chat_gpt_request)
return message
if status != httpx.codes.OK:
logger.info(f"got response status: {status} from chat api", data=chat_gpt_request)
return "Что-то пошло не так, попробуйте еще раз или обратитесь к администратору"
return response.text
except Exception as error:
logger.error("error get data from chat api", error=error)
return "Вообще всё сломалось :("
@staticmethod
async def do_request(data: dict[str, Any]) -> Response:
transport = AsyncHTTPTransport(retries=3)
async with AsyncClient(base_url=settings.GPT_BASE_HOST, transport=transport, timeout=50) as client:
return await client.post(CHAT_GPT_BASE_URI, json=data, timeout=50)
def build_request_data(self, question: str) -> dict[str, Any]:
return {
"conversation_id": str(uuid4()),
"action": "_ask",
"model": self.chat_gpt_model,
"jailbreak": "default",
"meta": {
"id": random.randint(10**18, 10**19 - 1), # noqa: S311
"content": {
"conversation": [],
"internet_access": False,
"content_type": "text",
"parts": [{"content": question, "role": "user"}],
},
},
}

View File

@ -2,18 +2,18 @@ import asyncio
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from unittest import mock from unittest import mock
import httpx
import pytest import pytest
import telegram import telegram
from assertpy import assert_that from assertpy import assert_that
from faker import Faker from faker import Faker
from httpx import AsyncClient from httpx import AsyncClient, Response
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from constants import BotStagesEnum from constants import BotStagesEnum
from core.bot import BotApplication, BotQueue from core.bot import BotApplication, BotQueue
from main import Application from main import Application
from settings.config import AppSettings, settings from settings.config import AppSettings, settings
from tests.integration.bot.conftest import mocked_ask_question_api
from tests.integration.bot.networking import MockedRequest from tests.integration.bot.networking import MockedRequest
from tests.integration.factories.bot import ( from tests.integration.factories.bot import (
BotCallBackQueryFactory, BotCallBackQueryFactory,
@ -21,6 +21,7 @@ from tests.integration.factories.bot import (
BotUpdateFactory, BotUpdateFactory,
CallBackFactory, CallBackFactory,
) )
from tests.integration.utils import mocked_ask_question_api
pytestmark = [ pytestmark = [
pytest.mark.asyncio, pytest.mark.asyncio,
@ -31,11 +32,6 @@ pytestmark = [
faker = Faker() faker = Faker()
async def test_bot_updates(rest_client: AsyncClient) -> None:
response = await rest_client.get("/api/healthcheck")
assert response.status_code == 200
async def test_bot_webhook_endpoint( async def test_bot_webhook_endpoint(
rest_client: AsyncClient, rest_client: AsyncClient,
main_application: Application, main_application: Application,
@ -169,8 +165,8 @@ async def test_about_bot_callback_action(
assert mocked_reply_text.call_args.args == ( assert mocked_reply_text.call_args.args == (
f"Бот использует бесплатную модель {settings.GPT_MODEL} для ответов на вопросы. " f"Бот использует бесплатную модель {settings.GPT_MODEL} для ответов на вопросы. "
f"Принимает запросы на разных языках.\n\nБот так же умеет переводить русские голосовые сообщения в текст. " f"\nПринимает запросы на разных языках.\n\nБот так же умеет переводить русские голосовые сообщения "
f"Просто пришлите голосовуху и получите поток сознания в виде текста, но без знаков препинания", f"в текст. Просто пришлите голосовуху и получите поток сознания в виде текста, но без знаков препинания",
) )
assert mocked_reply_text.call_args.kwargs == {"parse_mode": "Markdown"} assert mocked_reply_text.call_args.kwargs == {"parse_mode": "Markdown"}
@ -198,7 +194,10 @@ async def test_ask_question_action(
) -> None: ) -> None:
with mock.patch.object( with mock.patch.object(
telegram._bot.Bot, "send_message", return_value=lambda *args, **kwargs: (args, kwargs) telegram._bot.Bot, "send_message", return_value=lambda *args, **kwargs: (args, kwargs)
) as mocked_send_message, mocked_ask_question_api(host=test_settings.GPT_BASE_HOST): ) as mocked_send_message, mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST,
return_value=Response(status_code=httpx.codes.OK, text="Привет! Как я могу помочь вам сегодня?"),
):
bot_update = BotUpdateFactory(message=BotMessageFactory.create_instance(text="Привет!")) bot_update = BotUpdateFactory(message=BotMessageFactory.create_instance(text="Привет!"))
bot_update["message"].pop("entities") bot_update["message"].pop("entities")
@ -214,6 +213,55 @@ async def test_ask_question_action(
) )
async def test_ask_question_action_not_success(
main_application: Application,
test_settings: AppSettings,
) -> None:
with mock.patch.object(
telegram._bot.Bot, "send_message", return_value=lambda *args, **kwargs: (args, kwargs)
) as mocked_send_message, mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST, return_value=Response(status_code=httpx.codes.INTERNAL_SERVER_ERROR)
):
bot_update = BotUpdateFactory(message=BotMessageFactory.create_instance(text="Привет!"))
bot_update["message"].pop("entities")
await main_application.bot_app.application.process_update(
update=Update.de_json(data=bot_update, bot=main_application.bot_app.bot)
)
assert_that(mocked_send_message.call_args.kwargs).is_equal_to(
{
"text": "Что-то пошло не так, попробуйте еще раз или обратитесь к администратору",
"chat_id": bot_update["message"]["chat"]["id"],
},
include=["text", "chat_id"],
)
async def test_ask_question_action_critical_error(
main_application: Application,
test_settings: AppSettings,
) -> None:
with mock.patch.object(
telegram._bot.Bot, "send_message", return_value=lambda *args, **kwargs: (args, kwargs)
) as mocked_send_message, mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST,
side_effect=Exception(),
):
bot_update = BotUpdateFactory(message=BotMessageFactory.create_instance(text="Привет!"))
bot_update["message"].pop("entities")
await main_application.bot_app.application.process_update(
update=Update.de_json(data=bot_update, bot=main_application.bot_app.bot)
)
assert_that(mocked_send_message.call_args.kwargs).is_equal_to(
{
"text": "Вообще всё сломалось :(",
"chat_id": bot_update["message"]["chat"]["id"],
},
include=["text", "chat_id"],
)
async def test_no_update_message( async def test_no_update_message(
main_application: Application, main_application: Application,
test_settings: AppSettings, test_settings: AppSettings,

View File

@ -4,19 +4,16 @@ pytest framework. A common change is to allow monkeypatching of the class member
enforcing slots in the subclasses.""" enforcing slots in the subclasses."""
import asyncio import asyncio
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from contextlib import contextmanager
from datetime import tzinfo from datetime import tzinfo
from typing import Any, AsyncGenerator, Iterator from typing import Any, AsyncGenerator
import pytest import pytest
import pytest_asyncio import pytest_asyncio
import respx from httpx import AsyncClient
from httpx import AsyncClient, Response
from pytest_asyncio.plugin import SubRequest from pytest_asyncio.plugin import SubRequest
from telegram import Bot, User from telegram import Bot, User
from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot from telegram.ext import Application, ApplicationBuilder, Defaults, ExtBot
from constants import CHAT_GPT_BASE_URI
from core.bot import BotApplication from core.bot import BotApplication
from core.handlers import bot_event_handlers from core.handlers import bot_event_handlers
from main import Application as AppApplication from main import Application as AppApplication
@ -255,15 +252,3 @@ async def rest_client(
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) as client: ) as client:
yield client yield client
@contextmanager
def mocked_ask_question_api(host: str) -> Iterator[respx.MockRouter]:
with respx.mock(
assert_all_mocked=True,
assert_all_called=True,
base_url=host,
) as respx_mock:
ask_question_route = respx_mock.post(url=CHAT_GPT_BASE_URI, name="ask_question")
ask_question_route.return_value = Response(status_code=200, text="Привет! Как я могу помочь вам сегодня?")
yield respx_mock

View File

@ -0,0 +1,56 @@
import httpx
import pytest
from faker import Faker
from httpx import AsyncClient, Response
from settings.config import AppSettings
from tests.integration.utils import mocked_ask_question_api
pytestmark = [
pytest.mark.asyncio,
pytest.mark.enable_socket,
]
faker = Faker()
async def test_bot_updates(rest_client: AsyncClient) -> None:
response = await rest_client.get("/api/healthcheck")
assert response.status_code == 200
async def test_bot_healthcheck_is_ok(
rest_client: AsyncClient,
test_settings: AppSettings,
) -> None:
with mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST,
return_value=Response(status_code=httpx.codes.OK, text="Привет! Как я могу помочь вам сегодня?"),
):
response = await rest_client.get("/api/bot-healthcheck")
assert response.status_code == httpx.codes.OK
async def test_bot_healthcheck_invalid_request_model(
rest_client: AsyncClient,
test_settings: AppSettings,
) -> None:
with mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST,
return_value=Response(status_code=httpx.codes.OK, text="Invalid request model"),
):
response = await rest_client.get("/api/bot-healthcheck")
assert response.status_code == httpx.codes.INTERNAL_SERVER_ERROR
async def test_bot_healthcheck_not_ok(
rest_client: AsyncClient,
test_settings: AppSettings,
) -> None:
with mocked_ask_question_api(
host=test_settings.GPT_BASE_HOST,
side_effect=Exception(),
):
response = await rest_client.get("/api/bot-healthcheck")
assert response.status_code == httpx.codes.INTERNAL_SERVER_ERROR

View File

@ -0,0 +1,22 @@
from contextlib import contextmanager
from typing import Any, Iterator
import respx
from httpx import Response
from constants import CHAT_GPT_BASE_URI
@contextmanager
def mocked_ask_question_api(
host: str, return_value: Response | None = None, side_effect: Any | None = None
) -> Iterator[respx.MockRouter]:
with respx.mock(
assert_all_mocked=True,
assert_all_called=True,
base_url=host,
) as respx_mock:
ask_question_route = respx_mock.post(url=CHAT_GPT_BASE_URI, name="ask_question")
ask_question_route.return_value = return_value
ask_question_route.side_effect = side_effect
yield respx_mock

View File

@ -113,7 +113,7 @@ ignore = [
per-file-ignores = [ per-file-ignores = [
# too complex queries # too complex queries
"bot_microservice/tests/*: S101", "bot_microservice/tests/*: S101",
"bot_microservice/tests/integration/bot/conftest.py: NEW100", "bot_microservice/tests/integration/conftest.py: NEW100",
"bot_microservice/settings/config.py: S104" "bot_microservice/settings/config.py: S104"
] ]