@@ -5,6 +5,7 @@ from django.conf.urls.static import static from django.contrib import admin from django.core.exceptions import ObjectDoesNotExist from django.urls import include, path +from django.http import JsonResponse from django.utils.translation import gettext_lazy as _ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from ninja import NinjaAPI @@ -45,7 +46,12 @@ def invalid_username_error_handler(request, exc: InvalidUsername): return api.create_response(request, {'message': _('Wrong username')}, status=401) +def healthz_status(request): + return JsonResponse({"status": "ok"}, status=200) + + urlpatterns = [ + path('api/healthz/', healthz_status), path('ml_models/', include('ml_model.urls', namespace='ml_model')), path('stories/', include('stories.urls')), path('auth/', include('authentication.urls')), @@ -1144,3 +1144,15 @@ msgstr "При отправке письма произошла неизвест msgid "You cannot change the password of an unconfirmed e-mail user." msgstr "Вы не можете изменить пароль неподтвержденного по e-mail пользователя." + +msgid "Jinja template not found" +msgstr "Jinja-шаблон не найден" + +msgid "There was an unknown error while rendering a template" +msgstr "При рендеринге шаблона произошла неизвестная ошибка" + +msgid "The length of the context has been exceeded." +msgstr "Длина контекста превышена." + +msgid "The attached file format is not supported. Available formats: %(available_extensions)s." +msgstr "Формат вложенного файла не поддерживается. Доступные форматы: %(available_extensions)s." @@ -23,6 +23,7 @@ from ml_model.services.musicgen import Musicgen from ml_model.services.perplexity import Perplexity from ml_model.services.pulid import Pulid from ml_model.services.qwen import Qwen +from ml_model.services.raifgpt import Raifgpt from ml_model.services.recraft import Recraft from ml_model.services.sdxlemoji import Sdxlemoji from ml_model.services.stablediffusion import Stablediffusion @@ -104,6 +104,7 @@ class Chatgpt(SimpleService): start_time = time.time() info = input_message.info.copy() model_name = info.pop('version', 'gpt-4o') + user_system_prompt = info.pop('system_prompt', '') input_content = [{'type': 'text', 'text': input_message.content or ''}] file = input_message.file image = None @@ -164,13 +165,13 @@ class Chatgpt(SimpleService): runnable=self.llm, get_session_history=lambda _: chat_history, ) - llm_input = HumanMessage(content=input_content) + llm_input = [SystemMessage(content=user_system_prompt), HumanMessage(content=input_content)] if file and not image: - input_tokens = self.count_text_tokens([*chat_history.messages, llm_input, *chunks]) + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input, *chunks]) elif image: - input_tokens = self.count_text_tokens([llm_input]) + input_tokens = self.count_text_tokens(llm_input) else: - input_tokens = self.count_text_tokens([*chat_history.messages, llm_input]) + input_tokens = self.count_text_tokens([*chat_history.messages, *llm_input]) output_tokens = 0 self.assert_enough_balance(input_tokens, image_size, model=self.llm.model_name) if model_name in ('o3-mini', 'gpt-4.5-preview'): @@ -180,6 +181,7 @@ class Chatgpt(SimpleService): for msg in chat_history.messages ] messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) if image: messages[-1]['content'] = [ {'type': 'text', 'text': input_message.content}, @@ -204,6 +206,7 @@ class Chatgpt(SimpleService): ] if model_name not in ('o1-preview', 'o1-mini'): messages.insert(0, {'role': 'system', 'content': system.content}) + messages.insert(0, {'role': 'system', 'content': user_system_prompt}) search_context_size = search_context_sizes.get(info.get('web_search', 'Средний контекст')) info['web_search'] = search_context_size json_data = { @@ -219,42 +222,46 @@ class Chatgpt(SimpleService): } input_tokens, output_tokens, response = self.call_openai_api(proxy=proxy, endpoint='responses',json_data=json_data) elif image: - response = self.llm.invoke([llm_input]) + response = self.llm.invoke(llm_input) chat_history.add_ai_message(response) elif file: human_messages = [] chunk_responses = ['Содержание файла: '] - for chunk in chunks: - prompt = [ - { - 'type': 'text', - 'text': f'Сгенерируй 2-3 предложения, которые суммирует следующий текст. ' - f'Включи основную мысль текста и все значимые числовые данные. Текст: {chunk}', - } - ] - human_message = HumanMessage(content=prompt) - human_messages.append(human_message) - response = conversation.invoke( - {'input': human_message.content[0]['text']}, - config={'configurable': {'session_id': 'default'}}, - ) - chunk_responses.append(response.content) + if len(chunks) > 1: + for chunk in chunks: + prompt = [ + { + 'type': 'text', + 'text': f'Сгенерируй 2-3 предложения, которые суммирует следующий текст. ' + f'Включи основную мысль текста и все значимые числовые данные. Текст: {chunk}', + } + ] + human_message = HumanMessage(content=prompt) + human_messages.append(human_message) + response = self.llm.invoke([human_message]) + chunk_responses.append(response.content) + else: + chunk_responses.append(chunks[0].content) combined_summary = ' '.join(chunk_responses) - user_prompt = input_message.content if input_message.content.split() else 'Суммируй текст' question_content = ( f'Вот краткое содержание каждого чанка:\n' - f'{combined_summary}\nОтветьте на вопрос по содержанию файла: {user_prompt}' + f'{combined_summary}\n{input_message.content}' ) - input = HumanMessage(content=question_content) - input_tokens = self.count_text_tokens(human_messages + [input]) + input = [ + SystemMessage(content=user_system_prompt), + HumanMessage(content=f'Используй системный промпт. {question_content}') + ] + input_tokens = self.count_text_tokens(human_messages + input) response = conversation.invoke( - {'input': human_message.content[0]['text']}, + {'input': input}, config={'configurable': {'session_id': 'default'}}, ) else: # Somehow this chain doesn't support Vision, even though ChatOpenAI (above) does. + if model_name in ('o1-preview', 'o1-mini'): + llm_input.pop(0) response = conversation.invoke( - {'input': llm_input.content[0]['text']}, + {'input': llm_input}, config={'configurable': {'session_id': 'default'}}, ) chat_history.add_ai_message(response) @@ -340,7 +347,7 @@ class Chatgpt(SimpleService): def assert_enough_balance( self, input_tokens: int, - image_size: tuple, + image_size: tuple | None, model: str = 'gpt-3.5-turbo', ): balance = PaymentPlanSelector(self.store.user).get_current_balance() @@ -463,7 +470,7 @@ class Chatgpt(SimpleService): return 'Файл пуст или содержит изображения, из которых невозможно извлечь текст.' def split_text_to_chunks( - self, raw_text: str, chunk_size: int = 100_000, overlap: int = 300 + self, raw_text: str, chunk_size: int = 80_000, overlap: int = 300 ) -> list[HumanMessage]: """ Splitting file raw text to chunks @@ -61,9 +61,13 @@ class Claude(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() version = f'anthropic/{input_message.info.pop("version", "claude-3.7-sonnet:thinking")}' + system_prompt = input_message.info.pop('system_prompt', '') callback_data = {'provider': {'order': ['Anthropic']}, **input_message.info} - messages = self.get_chat_history() - messages.append({'role': 'user', 'content': input_message.content}) + messages = [ + {'role': 'system', 'content': system_prompt}, + *self.get_chat_history(), + {'role': 'user', 'content': input_message.content} + ] image = input_message.file if image: kind = filetype.guess(image.read(20)) @@ -47,11 +47,15 @@ class Deepseek(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: info = input_message.info.copy() version = info.pop('version') + system_prompt = input_message.info.pop('system_prompt', '') callback_data = { **input_message.info, } - messages = self.get_chat_history() - messages.append({'role': 'user', 'content': input_message.content}) + messages = [ + {'role': 'system', 'content': system_prompt}, + *self.get_chat_history(), + {'role': 'user', 'content': input_message.content} + ] start_time = time.time() result = openrouter_run(version, messages, callback_data, 'Deepseek') process_time = timedelta(seconds=(time.time() - start_time)) @@ -0,0 +1,115 @@ +import time +import httpx + +from django.template import TemplateDoesNotExist +from django.template.loader import get_template +from openai import BadRequestError + +from ml_model.exceptions import TemplateNotFound, TemplateUnknownException, FileExtensionNotSupported, \ + ExceededContextLengthError +from ml_model.services import Chatgpt + +from django.utils.translation import gettext_lazy as _ +from datetime import timedelta + +from pathlib import Path + +from langchain_core.messages import ( + HumanMessage, + SystemMessage, +) +from langchain_core.runnables import RunnableWithMessageHistory +from langchain_openai.chat_models import ChatOpenAI + +from messages.models import Message +from ml_model.exceptions import GenerationException + +from poller.models import Proxy + + +class Raifgpt(Chatgpt): + def make( + self, + input_message: Message, + save: bool = True, + ) -> list[Message]: + try: + template = get_template('ml_model/system_prompt.html') + user_system_prompt = template.render() + except TemplateDoesNotExist: + raise TemplateNotFound + except Exception as exc: + raise TemplateUnknownException from exc + input_content = [{'type': 'text', 'text': input_message.content or ''}] + file = input_message.file + if file: + file_extension = Path(file.name).suffix + if file_extension == '.pdf': + chunks = self.split_text_to_chunks(self.get_pdf_data(file)) + elif file_extension in ('.doc', '.docx'): + chunks = self.split_text_to_chunks(self.get_word_data(file_extension, file)) + else: + raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX']) + for proxy in Proxy.objects.all(): + self.llm = ChatOpenAI( + model='gpt-4o', + http_client=httpx.Client(proxy=f'{proxy.protocol}://{proxy.address}'), + ) + self.llm.tiktoken_model_name = 'gpt-4' + self.llm.temperature = 0.8 + self.llm.top_p = 1 + self.llm.presence_penalty = 0 + chat_history = self.get_chat_history() + conversation = RunnableWithMessageHistory( + runnable=self.llm, + get_session_history=lambda _: chat_history, + ) + llm_input = HumanMessage(content=input_content) + if file: + input_tokens = self.count_text_tokens([*chat_history.messages, llm_input, *chunks]) + else: + input_tokens = self.count_text_tokens([*chat_history.messages, llm_input]) + + self.assert_enough_balance(input_tokens, None, model=self.llm.model_name) + + start_time = time.time() + try: + if file: + input = [ + SystemMessage(content=user_system_prompt), + HumanMessage(content=f'Строго используй системный промпт. Вот содержание файла по чанкам:'), + *chunks, + llm_input + ] + input_tokens = self.count_text_tokens(input) + response = conversation.invoke( + {'input': input}, + config={'configurable': {'session_id': 'default'}}, + ) + else: + response = conversation.invoke( + {'input': llm_input}, + config={'configurable': {'session_id': 'default'}}, + ) + chat_history.add_ai_message(response) + except BadRequestError as exc: + if exc.code == 'context_length_exceeded': + raise ExceededContextLengthError + raise GenerationException + + output_tokens = self.count_text_tokens([response]) + + self.logger.info(f'Input количество токенов для gpt-4o - {input_tokens}') + self.logger.info(f'Output количество токенов для gpt-4o - {output_tokens}') + self.logger.info(f'Общее количество токенов для gpt-4o - {input_tokens + output_tokens}') + + process_time = timedelta(seconds=time.time() - start_time) + self.handle_invoice( + self.neuron_model, + input_tokens, + output_tokens, + self.llm.model_name, + {} + ) + msgs = self.save_results([response], process_time, save) + return msgs @@ -0,0 +1,72 @@ +# ВНИМАНИЕ: +1. ОТВЕЧАЙ СТРОГО ПО ШАБЛОНУ — не меняй порядок, не добавляй и не убирай пункты. Каждый пункт и подпункт должен быть представлен, даже если данных мало. +2. НЕ СОКРАЩАЙ ТЕКСТ СЛИШКОМ СИЛЬНО — не упрощай формулировки, не объединяй предложения, не убирай детали. Приводи ВСЕ указанные цифры, периоды, названия, суммы и т.д. Полнота важнее краткости. +3. ПЕРЕНОСЫ СТРОК: После КАЖДОГО ПУНКТА (например, 1., 2., 3.) и ПОДПУНКТА (например, a., b., c.) ОБЯЗАТЕЛЬНО ВСТАВЛЯЙ ПЕРЕНОС СТРОКИ. Один пункт = одна строка. Строго соблюдай разметку: каждый подпункт с новой строки, даже если он короткий. +Не пытайся оптимизировать или переформулировать текст. Следуй инструкции строго, как если бы ты заполнял юридически значимую форму. +# [GOAL] +# Твоя задача — создать детализированное, но краткое структурированное содержание предоставленного текста рыночного исследования. Выступай в роли аналитика, подготавливающего выжимку, богатую ключевыми данными, для быстрого обзора и оценки. + +# [RETURN FORMAT] +# Выходные данные должны строго соответствовать следующей многоуровневой структуре: + +# 1. Краткое содержание: +# a. Автор(ы), Тема, Дата/Год исследования – 1 предложение. +# b. Наименование основного(ых) рынка(ов) и его(их) краткое описание (что за рынок/продукт/сегмент) – 1-2 предложения. +# c. Краткое содержание основного заключения/выводов исследования – 2-3 предложения. + +# 2. Про рынок(и)/сегмент(ы)/продукт(ы): +# **_ВАЖНО: Если отчет охватывает несколько РАЗДЕЛЬНЫХ рынков/сегментов/продуктов, представь информацию по пунктам 2.a - 2.g ОТДЕЛЬНО для КАЖДОГО, четко обозначая, к какому рынку/сегменту/продукту относятся данные._** +# a. Объем и динамика рынка за последний период в исследовании (**ОБЯЗАТЕЛЬНО ПЕРВЫМ ДЕЛОМ УКАЖИ ОБЩИЙ ОБЪЕМ РЫНКА/ИНВЕСТИЦИЙ** в абсолютном значении (например, $46 млн) за указанный период (например, 1П 2024). Затем укажи динамику (% РОСТА/ПАДЕНИЯ) и ПЕРИОД СРАВНЕНИЯ (например, +31% к 1П 2023). Упомяни контекст, если он важен (например, *без учета сделки X*). Укажи ключевые причины динамики) – 1-2 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# b. Прогноз объемов и динамики рынка на следующий год и/или ближайшие 3-5 лет (ОБЯЗАТЕЛЬНО укажи прогнозируемые АБСОЛЮТНЫЕ значения, % РОСТА/ПАДЕНИЯ И ПЕРИОД СРАВНЕНИЯ/год прогноза, упомянутые в тексте) и ключевые причины прогнозируемой динамики – 2-4 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# c. Драйверы роста рынка (что будет способствовать развитию; перечисли КОНКРЕТНЫЕ факторы) – 2-3 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# d. Барьеры роста рынка (что будет тормозить развитие; перечисли КОНКРЕТНЫЕ факторы) – 2-3 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# e. Юридические/Регуляторные вопросы (основные законодательные или регуляторные изменения/факторы, влияющие на рынок) – 2-3 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# f. Основные сегменты внутри данного рынка (если применимо, по типу поставки, клиентам, продукции и т.д.) – 1-2 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. +# g. Основные игроки на рынке (Перечисли КОНКРЕТНЫЕ названия игроков/компаний, их ДОЛИ РЫНКА или ОБЪЕМЫ ВЫРУЧКИ/СДЕЛОК, примеры ключевых сделок/продуктов с СУММАМИ, если указано в тексте) – 2-3 предложения ДЛЯ КАЖДОГО РЫНКА/СЕГМЕНТА. + +# 3. Список метрик: +# a. Полный список КОЛИЧЕСТВЕННЫХ метрик (Извлеки ВСЕ упомянутые количественные метрики с их ЗНАЧЕНИЯМИ и ЕДИНИЦАМИ ИЗМЕРЕНИЯ, если доступны). +# b. Полный список КАЧЕСТВЕННЫХ метрик или факторов оценки, упоминаемых в отчете. + +# 4. Что еще есть в отчете: +# a. Краткое перечисление других типов ВАЖНОЙ информации или артефактов из отчета (например, примеры кейсов, графики, детализированные таблицы, интервью, методология, комментарии экспертов и т.п.). + +# [WARNINGS / CONSTRAINTS] +# - КРИТИЧЕСКИ ВАЖНО: Извлекай ВСЕ ключевые ЧИСЛОВЫЕ ДАННЫЕ (объемы, темпы роста/падения, проценты, абсолютные значения, денежные суммы, временные периоды, суммы сделок, доли рынка и т.д.). Не пропускай цифры и их контекст (например, "без учета сделки Х")! +# - Обработка нескольких сущностей: Если в тексте несколько рынков/сегментов/продуктов, структурируй данные по каждому из них отдельно в разделе 2, как указано в [RETURN FORMAT]. Не смешивай данные! +# - Строго по структуре: Не добавляй никакой информации или разделов, не предусмотренных форматом выше. +# - Максимальная точность: Используй ТОЛЬКО факты и формулировки из предоставленного текста. Не додумывай, не интерпретируй, не добавляй свое мнение. Следи за точностью терминов (например, "посевная", а не "осевная"). +# - Конкретика: Избегай общих и размытых фраз. Если в тексте упоминаются абстрактные понятия вроде "ведущие эксперты", постарайся указать конкретику (имена, компании), если она есть в тексте. +# - Единый ответ: Предоставь все содержание в одном цельном ответе, избегая ненужного разбиения (насколько позволяет лимит длины ответа). + +# [CONTEXT] +# Цель этой структурированной выжимки — позволить пользователю быстро оценить основные выводы, ключевые показатели и числовые данные по каждому рынку/сегменту, понять потенциал и риски, а также узнать, какая детальная информация (метрики, графики, кейсы) содержится в полном отчете, без необходимости читать его целиком. + +Ты пишешь рыночное исследование + +Стандартное рыночное исследование: + +1. Краткое содержание: +a. Автор, Тема, Дата исследования – 1 предложение +b. Наименование рынка и его описание (что за рынок) – 1 предложение +c. Краткое содержание заключения исследования – 2-3 предложения +2. Про рынок: +a. Объем и динамика рынка за последний период в исследовании (ХХХ млрд руб. в 20ХХ году и 12% роста по сравнению с 20ХХ-1 г.) и причины динамики – 1-2 предложения. +b. Прогноз объемов и динамики рынка на следующий год и ближайшие 3-5 лет (достигнет YYY млрд руб. к 20YY году или +20% к 20ХХ году) и причины динамики – 2-4 предложения. +c. Драйверы роста рынка (что будет способствовать развитию рынка) – 2-3 предложения +d. Барьеры роста рынка (что будет тормозить развитие рынка) – 2-3 предложения +e. Юридические вопросы (какие законодательные изменения могут ускорить или затормозить развитие рынка) – 2-3 предложения +f. Основные сегменты рынка (по типу поставки, по типам клиентов, по типам продукции и т.д.) – 1-2 предложения +g. Основные игроки на рынке (перечисление поставщиков и наименование их флагманской продукции) – 2-3 предложения. +3. Список метрик: +a. Полный список количественных и качественных метрик встречающихся в отчете (желательно отсортированные по мере убывания повторений в отчете) +4. Что еще есть в отчете: +a. Краткое перечисление других атрибутов информации из отчета (примеры реальных кейсов, графики, иллюстрации, прочее) + +Важно НЕ делать: +• Строго по структуре – не добавлять лишней информации. +Если будет перечисление метрик и атрибутов информации, я смогу понять есть ли нужное в отчете, открыть его и найти нужное. +Краткая выжимка по структуре будем помогать в верхнеуровневом определении потенциала рынка. +• Строго по фактам и формулировкам из отчета – не креативить. +• Избегать общих формулировок. +Пример: «выполненный при участии ведущих экспертов отечественного рынка.» - желательно пояснить в скобках каких экспертов? Названия организаций, должностей, фамилий и т.д. \ No newline at end of file @@ -20,3 +20,28 @@ class DeploymentDisabled(Exception): class ModelTimeoutError(Exception): def __str__(self): return _('The model is not responding') + + +class FileExtensionNotSupported(Exception): + def __init__(self, extensions: list[str]) -> None: + self.extensions = extensions + + def __str__(self) -> str: + return _( + f'The attached file format is not supported. Available formats: %(available_extensions)s.' + ) % {'available_extensions': ', '.join(self.extensions)} + + +class ExceededContextLengthError(Exception): + def __str__(self) -> str: + return _('The length of the context has been exceeded.') + + +class TemplateNotFound(Exception): + def __str__(self): + return _('Jinja template not found') + + +class TemplateUnknownException(Exception): + def __str__(self): + return _('There was an unknown error while rendering a template') @@ -10,6 +10,7 @@ from rest_framework.generics import ( from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.status import ( + HTTP_400_BAD_REQUEST, HTTP_402_PAYMENT_REQUIRED, HTTP_500_INTERNAL_SERVER_ERROR, HTTP_503_SERVICE_UNAVAILABLE @@ -18,7 +19,8 @@ from rest_framework.views import APIView from messages.models import Message from messages.serializers import MessageSerializer -from ml_model.exceptions import DeploymentDisabled +from ml_model.exceptions import DeploymentDisabled, TemplateNotFound, TemplateUnknownException, \ + FileExtensionNotSupported, ExceededContextLengthError from ml_model.services.base import SimpleService from payments.exceptions.insufficient_balance import InsufficientBalance from tools.chats.models import Chat @@ -157,6 +159,15 @@ class MessagesAPIView(APIView): }, status=HTTP_503_SERVICE_UNAVAILABLE, ) + except FileExtensionNotSupported as exc: + return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) + except ExceededContextLengthError as exc: + return Response({'detail': f'{exc}'}, status=HTTP_400_BAD_REQUEST) + except TemplateNotFound as exc: + return Response({'detail': f'{exc}'}, status=HTTP_500_INTERNAL_SERVER_ERROR) + except TemplateUnknownException as exc: + logger.exception(exc) + return Response({'detail': f'{exc}'}, status=HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: input_message.is_sent = False input_message.save() @@ -10,10 +10,6 @@ services: tags: - $CI_REGISTRY_IMAGE:latest - $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - cache_from: - - type=registry,ref=$CI_REGISTRY_IMAGE/cache,ignore-error=true - cache_to: - - type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max,ignore-error=true volumes: - static:/code/static command: