@@ -0,0 +1,3 @@ +from analytics.exceptions.report_exceptions import EmptyMessagesReport + +__all__ = ('EmptyMessagesReport',) @@ -0,0 +1,6 @@ +from django.utils.translation import gettext as _ + + +class EmptyMessagesReport(Exception): + def __str__(self): + return _('No messages found for the selected period') @@ -0,0 +1,147 @@ +import hashlib +import json +import os +from datetime import timedelta + +from django.core.cache import cache +from django.http import FileResponse +from django.utils import timezone + +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, OuterRef, Subquery +from django.db.models.functions import TruncDate, TruncMonth, TruncWeek +from ninja.errors import HttpError + +from analytics.exceptions import EmptyMessagesReport +from analytics.schemas import EmployeeGenerationSchema +from analytics.services.period_service import AnalyticsPeriodService +from analytics.services.report_services.excel_report_service import ExcelActivityMetricReportService +from authentication.exceptions.business_host_exceptions.access_denied import AccessDenied +from authentication.security import SyncAuthBearer + +from ninja import Query, Router + +from messages.models import Message +from tools.chats.models import Chat +from tools.media.models import Audio, Image, Video +from tools.public_api.models import APIStore + +router = Router(auth=SyncAuthBearer(), tags=['analytics']) + + +@router.get('business/employees-generations', tags=['analytics/business'], response={200: None}) +def download_employees_generations(request, payload: Query[EmployeeGenerationSchema]): + """ + Generates an XLSX file that provides information + about the number of employee requests for models during a period + """ + try: + if request.auth.account_type != 'business_host': + raise AccessDenied + + host = request.auth.host + employees_emails = [acc.user.email for acc in host.accounts.all()] + ap_service = AnalyticsPeriodService + + tz = timezone.get_current_timezone() + if q := payload.quarter: + period = ap_service.get_quarter_by_alias(q, payload.year) + elif payload.start and payload.end: + period = ap_service.get_period_by_timerange(payload.start, payload.end) + + groupby = { + 'dau': TruncDate('created_at', tzinfo=tz), + 'wau': TruncWeek('created_at', tzinfo=tz), + 'mau': TruncMonth('created_at', tzinfo=tz), + } + + cache_key = ( + f'analytics:employees_generations:' + f'{host.uid}:' + f'{hashlib.sha256(json.dumps(sorted(employees_emails), ensure_ascii=True).encode()).hexdigest()}:' + f'{period.start.isoformat()}:' + f'{period.end.isoformat()}:' + f'{payload.metric.value}' + ) + messages = cache.get(cache_key) + if messages is None: + messages: dict[str, dict[str, dict[str, int]]] = {} + for store_model in (Audio, Video, Image, Chat, APIStore): + ct = ContentType.objects.get_for_model(store_model) + user_email_subquery = ( + store_model.objects.filter(uid=OuterRef('object_id')) + .select_related('user') + .values('user__email')[:1] + ) + rows = ( + Message.objects.filter( + content_type=ct, + from_model=True, + created_at__gte=ap_service.to_aware_datetime(period.start), + created_at__lte=ap_service.to_aware_datetime(period.end, end=True), + ) + .annotate(owner_user_email=Subquery(user_email_subquery)) + .filter(owner_user_email__in=employees_emails) + .annotate(groupby=groupby[payload.metric.value]) + .values('owner_user_email', 'groupby') + .annotate(total=Count('uid')) + ) + for row in rows: + gb = row['groupby'] + day = gb.date() if hasattr(gb, 'date') else gb + bucket = day.isoformat() + email = str(row['owner_user_email']) + + if payload.metric.value == 'wau': + week_end = day + timedelta(days=6) + sheets = { + (day + timedelta(days=i)).strftime('%Y-%m') + for i in range((week_end - day).days + 1) + } + else: + sheets = {day.strftime('%Y-%m')} + + for sheet in sheets: + messages.setdefault(sheet, {}).setdefault(bucket, {}).setdefault(email, 0) + messages[sheet][bucket][email] += row['total'] + if not messages: + raise EmptyMessagesReport + cache.set(cache_key, messages, 60 * 15) + except AccessDenied as exc: + raise HttpError(401, str(exc)) + except EmptyMessagesReport as exc: + raise HttpError(404, str(exc)) from exc + except Exception as exc: + raise HttpError(400, str(exc)) from exc + excel_report = ExcelActivityMetricReportService() + excel_report.create_document( + data=messages, + period=period, + emails=employees_emails, + metric=payload.metric.value, + time_aggregation=payload.aggregation.value, + ) + document_data = open(excel_report.tempfile.name, mode='rb') + filename = ( + f'{payload.metric.value.upper()}_' + f'{ + f"{period.start}_{period.end}" + if not (q := payload.quarter) + else f"{q.value.upper()}_{payload.year or timezone.now().year}" + }' + f'.xlsx' + ) + response = FileResponse( + document_data, + filename=filename, + as_attachment=True, + ) + + def _cleanup_tempfile(): + try: + os.remove(excel_report.tempfile.name) + except OSError: + pass + + response._resource_closers.append(_cleanup_tempfile) + return response @@ -0,0 +1,14 @@ +import tempfile +from abc import ABC, abstractmethod + + +class BaseReportService(ABC): + def __init__(self, file_suffix: str) -> None: + self.__file_suffix = file_suffix + self.tempfile = self.__initialize_temp_file() + + @abstractmethod + def create_document(self, *args, **kwargs): ... + + def __initialize_temp_file(self): + return tempfile.NamedTemporaryFile(suffix=self.__file_suffix, delete=False) @@ -0,0 +1,176 @@ +from collections import defaultdict +from datetime import date, timedelta +from typing import Literal + +from dateutil.relativedelta import relativedelta +from pyexcelerate import Workbook, Style, Font, Alignment + +from analytics.domain import Period + +from .base_report_service import BaseReportService + + +class BaseExcelReportService(BaseReportService): + def __init__(self) -> None: + self.workbook = Workbook() + super().__init__(file_suffix='.xlsx') + + def create_document(self, *args, **kwargs): ... + + +class ExcelActivityMetricReportService(BaseExcelReportService): + def create_document( + self, + data: dict[str, dict[str, dict[str, int]]], + period: Period, + emails: list[str], + metric: Literal['dau', 'wau', 'mau'] = 'dau', + time_aggregation: Literal['month', 'quarter'] = 'month', + ): + match time_aggregation: + case 'month': + self._create_month_statistics(data, period, metric, emails) + case 'quarter': + self._create_quarter_statistics(data, period, metric, emails) + case _: + raise NotImplementedError + + def _create_month_statistics( + self, + data: dict[str, dict[str, dict[str, int]]], + period: Period, + metric: str, + emails: list[str], + ) -> None: + month = period.start.replace(day=1) + while month <= period.end: + sheet = month.strftime('%Y-%m') + month_data = data.get(sheet, {}) + buckets = self._buckets(metric, month, period) + + rows = [['Email', *[self._column_label(b, metric) for b in buckets]]] + rows += [[email, *[month_data.get(b, {}).get(email, 0) for b in buckets]] for email in emails] + ws = self.workbook.new_sheet(sheet, data=rows) + ws.set_col_style(1, Style(size=35)) + ws.set_row_style( + 1, + Style(font=Font(bold=True), alignment=Alignment(horizontal='center', vertical='center')), + ) + for col in range(2, len(rows[1]) + 1): + ws.set_col_style(col, Style(size=15)) + month += relativedelta(months=1) + + self.tempfile.close() + self.workbook.save(self.tempfile.name) + + @staticmethod + def _buckets(metric: str, month_start: date, period: Period) -> list[str]: + start = max(period.start, month_start) + end = min(period.end, month_start + relativedelta(months=1, days=-1)) + + if metric == 'mau': + return [month_start.isoformat()] + + if metric == 'dau': + buckets, day = [], start + while day <= end: + buckets.append(day.isoformat()) + day += timedelta(days=1) + return buckets + + buckets, day = [], start - timedelta(days=start.weekday()) + while day <= end: + if day + timedelta(days=6) >= start: + buckets.append(day.isoformat()) + day += timedelta(days=7) + return buckets + + @staticmethod + def _column_label(bucket: str, metric: str) -> str: + day = date.fromisoformat(bucket) + if metric == 'wau': + return f'{day:%d.%m} — {(day + timedelta(days=6)):%d.%m}' + if metric == 'mau': + return day.strftime('%m.%Y') + return day.strftime('%d.%m') + + def _create_quarter_statistics( + self, + data: dict[str, dict[str, dict[str, int]]], + period: Period, + metric: str, + emails: list[str], + ) -> None: + quarter = self._quarter_start(period.start) + while quarter <= period.end: + quarter_end = min(period.end, quarter + relativedelta(months=3, days=-1)) + quarter_start = max(period.start, quarter) + buckets = self._quarter_buckets(metric, quarter_start, quarter_end) + quarter_data = self._quarter_data(data, quarter, quarter_end, metric) + quarter_number = ((quarter.month - 1) // 3) + 1 + sheet_name = f'{quarter.year}-Q{quarter_number}' + + rows = [['Email', *[self._column_label(b, metric) for b in buckets]]] + rows += [[email, *[quarter_data.get(b, {}).get(email, 0) for b in buckets]] for email in emails] + ws = self.workbook.new_sheet(sheet_name, data=rows) + ws.set_col_style(1, Style(size=35)) + ws.set_row_style( + 1, + Style(font=Font(bold=True), alignment=Alignment(horizontal='center', vertical='center')), + ) + for col in range(2, len(rows[1]) + 1): + ws.set_col_style(col, Style(size=15)) + quarter += relativedelta(months=3) + + self.tempfile.close() + self.workbook.save(self.tempfile.name) + + @staticmethod + def _quarter_start(day: date) -> date: + month = ((day.month - 1) // 3) * 3 + 1 + return date(day.year, month, 1) + + @staticmethod + def _quarter_buckets(metric: str, start: date, end: date) -> list[str]: + if metric == 'dau': + buckets, day = [], start + while day <= end: + buckets.append(day.isoformat()) + day += timedelta(days=1) + return buckets + + if metric == 'wau': + buckets, day = [], start - timedelta(days=start.weekday()) + while day <= end: + if day + timedelta(days=6) >= start: + buckets.append(day.isoformat()) + day += timedelta(days=7) + return buckets + + buckets, month = [], start.replace(day=1) + while month <= end: + buckets.append(month.isoformat()) + month += relativedelta(months=1) + return buckets + + @staticmethod + def _quarter_data( + data: dict[str, dict[str, dict[str, int]]], + quarter_start: date, + quarter_end: date, + metric: str, + ) -> dict[str, dict[str, int]]: + quarter_data = defaultdict(lambda: defaultdict(int)) + month = quarter_start + while month <= quarter_end: + sheet = month.strftime('%Y-%m') + month_data = data.get(sheet, {}) + for bucket, by_email in month_data.items(): + if metric == 'wau': + bucket_month = date.fromisoformat(bucket).strftime('%Y-%m') + if bucket_month != sheet: + continue + for email, value in by_email.items(): + quarter_data[bucket][email] += value + month += relativedelta(months=1) + return quarter_data @@ -0,0 +1,26 @@ +from datetime import date, datetime, time +from typing import Literal + +from dateutil.relativedelta import relativedelta +from django.utils.timezone import make_aware + +from analytics.domain import Period, Quarter + + +class AnalyticsPeriodService: + @staticmethod + def get_quarter_by_alias( + quarter_alias: Literal['q1', 'q2', 'q3', 'q4'] = 'q1', year_value: int | None = None + ) -> Quarter: + year = year_value or date.today().year + q_start = date(year, 1 + 3 * (int(quarter_alias[1:]) - 1), 1) + q_end = q_start + relativedelta(months=3, days=-1) + return Quarter(start=q_start, end=q_end, alias=quarter_alias) + + @staticmethod + def get_period_by_timerange(start: date, end: date) -> Period: + return Period(start=start, end=end) + + @staticmethod + def to_aware_datetime(date_value: date, *, end: bool = False) -> datetime: + return make_aware(datetime.combine(date_value, time.max if end else time.min)) @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AnalyticsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'analytics' @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from datetime import date + + +@dataclass +class Period: + start: date + end: date + + +@dataclass +class Quarter(Period): + alias: str | None = None @@ -0,0 +1,36 @@ +from datetime import date + +from django.utils.translation import gettext as _ +from ninja import Schema +from pydantic import model_validator + +from analytics.typing import ActivityMetricEnum, QuarterEnum, TimeAggregationEnum + + +class DateRangeSchema(Schema): + start: date | None = None + end: date | None = None + + +class QuarterSchema(Schema): + quarter: QuarterEnum | None = None + year: int | None = None + + +class EmployeeGenerationSchema(QuarterSchema, DateRangeSchema): + metric: ActivityMetricEnum = ActivityMetricEnum.MAU + aggregation: TimeAggregationEnum = TimeAggregationEnum.MONTH + + @model_validator(mode='after') + def check_period(self): + if (self.quarter or self.year) and (self.start or self.end): + raise ValueError(_('Provide either quarter or date range, not both')) + if self.quarter: + return self + if self.year: + raise ValueError(_('Year can only be used together with quarter')) + if not self.start or not self.end: + raise ValueError(_('Provide quarter or start and end dates')) + if self.start > self.end: + raise ValueError(_('Start date must be before or equal to end date')) + return self @@ -0,0 +1,19 @@ +from enum import Enum + + +class QuarterEnum(str, Enum): + Q1 = 'q1' + Q2 = 'q2' + Q3 = 'q3' + Q4 = 'q4' + + +class ActivityMetricEnum(str, Enum): + DAU = 'dau' + WAU = 'wau' + MAU = 'mau' + + +class TimeAggregationEnum(str, Enum): + MONTH = 'month' + QUARTER = 'quarter' @@ -3,7 +3,7 @@ from authentication.exceptions.business_host_exceptions.already_account import ( ) from authentication.exceptions.business_host_exceptions.already_has_plan import ( AlreadyHasPlan, - InviteeHasPlan + InviteeHasPlan, ) from authentication.exceptions.business_host_exceptions.already_host import ( AlreadyHost, @@ -5,7 +5,7 @@ class AlreadyHasPlan(Exception): def __str__(self) -> str: return _( 'You already have an active tariff plan. You must request a ' - 'cancellation of your current tariff plan (via the \"Report an error\" button), after which ' + 'cancellation of your current tariff plan (via the "Report an error" button), after which ' 'you will be able to create a Corporate Account' ) @@ -14,6 +14,6 @@ class InviteeHasPlan(Exception): def __str__(self) -> str: return _( 'Invitee already has an active tariff plan. Invitee must request a ' - 'cancellation of his current tariff plan (via the \"Report an error\" button), after which ' + 'cancellation of his current tariff plan (via the "Report an error" button), after which ' 'you will be able to invite him' ) @@ -1,11 +1,4 @@ -from authentication.exceptions.email_exceptions.letter_not_found import ( - LetterNotFound -) -from authentication.exceptions.email_exceptions.letter_unknown import ( - LetterUnknownException -) +from authentication.exceptions.email_exceptions.letter_not_found import LetterNotFound +from authentication.exceptions.email_exceptions.letter_unknown import LetterUnknownException -__all__ = ( - 'LetterNotFound', - 'LetterUnknownException' -) \ No newline at end of file +__all__ = ('LetterNotFound', 'LetterUnknownException') @@ -101,10 +101,6 @@ class BusinessUserHost(BaseModel): def display_name(self) -> str: return self.company_name or self.user.email - @property - def accounts(self) -> QuerySet[BusinessAccount]: - return self.accounts - @property def groups(self) -> QuerySet[BusinessGroup]: return self.company_groups.all() @@ -16,8 +16,6 @@ class EmailToken(BaseModel): key = models.CharField(max_length=30, verbose_name=_('Key')) class Meta: - indexes = [ - Index(fields=['key'], name='idx_email_token_key') - ] + indexes = [Index(fields=['key'], name='idx_email_token_key')] verbose_name = _('Email Token') verbose_name_plural = _('Email Tokens') @@ -19,4 +19,6 @@ class AccountStatusSelector: return False def is_admin(self) -> bool: - return self.user.business_account.account_privileges == 'admin' if self.is_business_account() else False \ No newline at end of file + return ( + self.user.business_account.account_privileges == 'admin' if self.is_business_account() else False + ) @@ -83,7 +83,8 @@ class BusinessHostSelector: else: raise Exception(_("User haven't rights to access host account information")) return BusinessHostSerializer( - host, context={'worker_amount': host.accounts.count(), 'token_cap_enabled': host.token_cap_enabled} + host, + context={'worker_amount': host.accounts.count(), 'token_cap_enabled': host.token_cap_enabled}, ) def get_per_model_statistics(self): @@ -72,7 +72,9 @@ class BusinessHostService: EmailService.send_corporate_greeting_email(account_service.account, password) return account_service - def create_existing(self, email: str, account_privileges: str, group: UUID | None = None) -> BusinessAccountService: + def create_existing( + self, email: str, account_privileges: str, group: UUID | None = None + ) -> BusinessAccountService: company = self.user.host or self.user.employee.parent_company if self.user.account_type == 'business_admin' and account_privileges == 'admin': raise AdminCreateForbidden @@ -91,7 +93,9 @@ class BusinessHostService: company, account_privileges=account_privileges, ) - account_service.account.group = BusinessGroup.objects.filter(uid=group, parent_company=company).first() + account_service.account.group = BusinessGroup.objects.filter( + uid=group, parent_company=company + ).first() account_service.account.save() if not user.is_deleted: EmailService.send_corporate_invitation_email(account_service.account, company) @@ -19,9 +19,7 @@ class MeAPITest(BaseAuthorizedAPITest): @classmethod def setup_test_data(cls) -> None: - cls.payment_plan, _ = PaymentPlan.objects.update_or_create( - price=0, tokens_per_plan=10, defaults={} - ) + cls.payment_plan, _ = PaymentPlan.objects.update_or_create(price=0, tokens_per_plan=10, defaults={}) cls.setup_host() def test_unauthorized_status_code(self) -> None: @@ -91,4 +89,3 @@ class MeAPITest(BaseAuthorizedAPITest): BusinessAccount.objects.create(user=self.user, parent_company=self.host) payment_plan_uid = self.get().json()['payment_plan']['plan']['uid'] self.assertEqual(str(payment_plan_uid), str(self.host_payment_plan.uid)) - @@ -1,6 +1,7 @@ # Authentication mapper from django.db.models import Count, Prefetch, Q +from authentication.models.business_account import BusinessAccount from payments.models.payment_plan_feature import PaymentPlanFeature from payments.models.user_payment_method import PaymentMethod @@ -194,6 +195,27 @@ PATH_PREFETCH_MAP = { *_gen_only('payment_plan__plan', 'uid', 'price'), ), }, + '/api/v1/analytics/business/employees-generations': { + 'select': ( + 'host_account', + 'host_account__company_companyipwhitelist', + ), + 'prefetch': ( + Prefetch( + 'host_account__accounts', + queryset=BusinessAccount.objects.select_related('user').only( + 'parent_company_id', + 'user_id', + 'user__email', + ), + ), + ), + 'only': ( + 'uid', + *_gen_only('host_account', 'uid'), + *_gen_only('host_account__company_companyipwhitelist', 'uid', 'is_enabled'), + ), + }, '/api/v1/payments/restore-subscription': { 'select': ( 'host_account', @@ -9,12 +9,11 @@ from django.dispatch import receiver @receiver([post_save, post_delete], sender=BusinessAccount) def invalidate_user_cache(sender, instance, signal, **kwargs): - cache_keys = cache.conn.smembers(dnfs_to_conj_keys( - '', - {'authentication_customusermodel': [{'uid': instance.user_id}]} - )[0]) + cache_keys = cache.conn.smembers( + dnfs_to_conj_keys('', {'authentication_customusermodel': [{'uid': instance.user_id}]})[0] + ) for key in cache_keys: data = cache.get(key.decode()) if isinstance(data, list) and isinstance((user := data[0]), CustomUserModel): user.business_account = instance if signal == post_save else None - cache.set(key.decode(), [user]) \ No newline at end of file + cache.set(key.decode(), [user]) @@ -11,6 +11,7 @@ app.autodiscover_tasks() from lib.unleash.client import celery_client + @worker_process_init.connect def configure_workers(sender=None, conf=None, **kwargs): celery_client.client.initialize_client(fetch_toggles=False) @@ -79,6 +79,7 @@ TOOLS = [ 'tools.apps.PublicAPIConfig', 'tools.apps.ChatsConfig', 'tools.apps.MediaConfig', + 'tools.apps.ShareConfig', ] @@ -274,6 +275,10 @@ CELERY_BEAT_SCHEDULE = { 'task': 'payments.tasks.send_low_balance_message', 'schedule': crontab(0, 8), }, + 'delete_expired_shares': { + 'task': 'tools.share.tasks.delete_expired_shares', + 'schedule': crontab('0', '*/6', '*', '*', '*'), + }, 'execute_recurring_payments': { 'task': 'payments.tasks.execute_recurring_payments', 'schedule': crontab(*env.list('RECURRING_PAYMENT_CRONTAB_SCHEDULE', [])), @@ -475,6 +480,7 @@ if CACHEOPS_REDIS: 'ml_model.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.chats.*': {'ops': 'all', 'timeout': 60 * 60}, 'tools.media.*': {'ops': 'all', 'timeout': 60 * 60}, + 'tools.share.*': {'ops': 'all', 'timeout': 60 * 60}, 'payments.paymentplan': {'ops': 'all', 'timeout': 60 * 60}, 'payments.invoice': {'ops': 'all', 'timeout': 60 * 60 * 24 * 7}, 'messages.*': {'ops': 'all', 'timeout': 60 * 60}, @@ -497,3 +503,6 @@ RECURRING_FAILED_CHARGE_EMAIL_DAYS = env.list('RECURRING_FAILED_CHARGE_EMAIL_DAY # SSE STREAMING FF__STREAMING_ENABLED = env.bool('FF__STREAMING_ENABLED', False) + +# MESSAGE SHARING +SHARE_LIFETIME = env.int('SHARE_LIFETIME', 1800) @@ -9,6 +9,7 @@ from django.urls import include, path from django.utils.translation import gettext_lazy as _ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from ninja import NinjaAPI, Schema +from ninja.errors import ValidationError from authentication.exceptions import ( InvalidPassword, @@ -30,11 +31,13 @@ public_api = NinjaAPI( api.add_router('users/', 'users.routes.v1.router') api.add_router('chats/', 'tools.chats.routes.v1.router') api.add_router('media/', 'tools.media.routes.v1.router') +api.add_router('share/', 'tools.share.routes.v1.router') compatibility_api.add_router('auth/', 'authentication.routes.v1.router') compatibility_api.add_router('payments/', 'payments.routes.v1.router') compatibility_api.add_router('reports/', 'reports.routes.v1.router') compatibility_api.add_router('ml_model/', 'ml_model.routes.v1.router') +compatibility_api.add_router('analytics/', 'analytics.routes.v1.router') compatibility_api_v2.add_router('auth/', 'authentication.routes.v2.router') @@ -97,6 +100,15 @@ def invalid_password_error_handler(request, exc: InvalidPassword): return api.create_response(request, {'message': _('Wrong password')}, status=401) +@compatibility_api.exception_handler(ValidationError) +def compatibility_validation_error(request, exc: ValidationError): + return compatibility_api.create_response( + request, + {'detail': exc.errors[0]['ctx']['error']}, + status=422, + ) + + urlpatterns = [] if settings.DEBUG: @@ -138,4 +150,4 @@ urlpatterns += ( ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + public_urlpatterns -) \ No newline at end of file +) @@ -103,5 +103,3 @@ class BaseAuthorizedAPITest(BaseAPITest): @abstractmethod def test_authorized_status_code(self) -> None: ... - - @@ -5,7 +5,6 @@ from lib.typing import Email, State class FeatureFlagService(ABC): - @abstractmethod def get_flag_state_by_emails(self, name: str, emails: List[Email]) -> Mapping[Email, State]: pass @@ -23,5 +23,5 @@ class UnleashRedisCache(BaseCache): def destroy(self): client = self.cache.client.get_client(write=True) - for key in client.scan_iter(f"{self.PREFIX}*"): + for key in client.scan_iter(f'{self.PREFIX}*'): client.delete(key) @@ -1,4 +1,4 @@ from lib.services.unleash_feature_flag import UnleashFeatureFlagService web_client = UnleashFeatureFlagService() -celery_client = UnleashFeatureFlagService() \ No newline at end of file +celery_client = UnleashFeatureFlagService() @@ -1,2 +1,2 @@ type Email = str -type State = bool \ No newline at end of file +type State = bool @@ -1480,6 +1480,10 @@ msgstr "Публичный API" msgid "Media" msgstr "Медиа" +#: tools/apps.py:32 tools/share/models.py:17 +msgid "Share" +msgstr "Шеринг" + #: tools/chats/apis.py:219 tools/media/apis.py:246 #: tools/public_api/views/base.py:108 msgid "" @@ -1715,6 +1719,50 @@ msgstr "Название голоса успешно обновлено" msgid "Preset voices are shared and cannot be deleted. Use your own voice id." msgstr "Пресеты общие и не удаляются. Используйте id собственного голоса." +#: tools/share/exceptions.py:6 +msgid "Some messages are unavailable to you" +msgstr "Некоторые сообщения вам недоступны" + +#: tools/share/exceptions.py:11 +msgid "Link access can only include messages from one store" +msgstr "Можно открыть доступ по ссылке только к сообщениям из одного хранилища" + +#: tools/share/exceptions.py:16 +msgid "Link access is not available for this store type" +msgstr "Доступ по ссылке для этого типа хранилища недоступен" + +#: tools/share/exceptions.py:21 +msgid "Link access for this store can only include model messages" +msgstr "Для этого хранилища в ссылку можно добавить только сообщения от модели" + +#: tools/share/exceptions.py:26 +msgid "Not all messages were found. Some may have been deleted" +msgstr "Не все сообщения найдены. Возможно, часть уже удалена" + +#: tools/share/exceptions.py:31 +msgid "The link was not found or is no longer available" +msgstr "Ссылка не найдена или больше не действует" + +#: tools/share/models.py:8 +msgid "Code" +msgstr "Код" + +#: tools/share/models.py:9 +msgid "Messages" +msgstr "Сообщения" + +#: tools/share/models.py:10 +msgid "Created At" +msgstr "Создано" + +#: tools/share/models.py:11 +msgid "Expires At" +msgstr "Истекает" + +#: tools/share/models.py:18 +msgid "Shares" +msgstr "Шеринги" + #~ msgid "Attempts" #~ msgstr "Попытки" @@ -17,11 +17,11 @@ class MessageAdmin(admin.ModelAdmin): 'file', 'from_public_api', 'is_sent', - 'info' + 'info', ) raw_id_fields = ('content_type',) - @admin.display(description="Связанный объект") + @admin.display(description='Связанный объект') def content_object_link(self, obj): if c_obj := obj.content_object: url = reverse(f'admin:{c_obj._meta.app_label}_{c_obj._meta.model_name}_change', args=[c_obj.pk]) @@ -41,7 +41,9 @@ class MessageSerializer(serializers.ModelSerializer): file = data.get('file') max_mb_size = 50 if file and file.size > (max_mb_size << 10 << 10): - raise ValidationError(_('The file size cannot exceed %(max_mb_size)d MB') % {'max_mb_size': max_mb_size}) + raise ValidationError( + _('The file size cannot exceed %(max_mb_size)d MB') % {'max_mb_size': max_mb_size} + ) return data def to_representation(self, instance): @@ -1 +1 @@ -from ml_model.adapters.bytedance_model_ark import BytedanceModelArkAdapter \ No newline at end of file +from ml_model.adapters.bytedance_model_ark import BytedanceModelArkAdapter @@ -319,9 +319,7 @@ class BytedanceModelArkAdapter: chunk = delta.get('content') or '' if chunk: yield RawSSEChunk(event='token', data={'content': chunk}) - if ( - fr := choices[0].get('finish_reason') - ) and fr not in ( + if (fr := choices[0].get('finish_reason')) and fr not in ( BytedanceFinishReason.STOP, BytedanceFinishReason.LENGTH, ): @@ -381,7 +379,9 @@ class BytedanceModelArkAdapter: raise InputImageSensitiveContentError if image_data := data.get('data'): - urls = [item.get('url') for item in image_data if isinstance(item, dict) and item.get('url')] + urls = [ + item.get('url') for item in image_data if isinstance(item, dict) and item.get('url') + ] if urls: return urls if cls._is_request_blocked_error(data): @@ -530,4 +530,4 @@ class BytedanceModelArkAdapter: total_tokens += token_info['total_tokens'] return total_tokens except: - return 0 \ No newline at end of file + return 0 @@ -38,7 +38,7 @@ class NeuronModelSelector: 'audio': 'audio', 'video': 'videos', 'code': 'code', - 'voice': 'voice' + 'voice': 'voice', } models = NeuronModel.objects.prefetch_related( Prefetch( @@ -57,13 +57,21 @@ class NeuronModelSelector: hidden: bool = False, ): if self.user.is_anonymous: - models = NeuronModel.objects.prefetch_related(Prefetch('model_modelstats')).filter(model_settings__is_active=True) + models = NeuronModel.objects.prefetch_related(Prefetch('model_modelstats')).filter( + model_settings__is_active=True + ) return NeuronModelsSerializer(models, many=True) user_type = UserSelector(self.user).check_account_type() models = NeuronModel.objects.annotate( - has_chat_msgs=Exists(Chat.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), - has_image_msgs=Exists(Image.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), - has_video_msgs=Exists(Video.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False)), + has_chat_msgs=Exists( + Chat.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False) + ), + has_image_msgs=Exists( + Image.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False) + ), + has_video_msgs=Exists( + Video.objects.filter(user=self.user, model=OuterRef('uid'), messages__isnull=False) + ), ).filter( ( Q(private_models_hosts__isnull=True) @@ -72,17 +80,10 @@ class NeuronModelSelector: ) & ( Q(model_settings__is_active=True) - | ( - Q(has_chat_msgs=True) - | Q(has_image_msgs=True) - | Q(has_video_msgs=True) - ) + | (Q(has_chat_msgs=True) | Q(has_image_msgs=True) | Q(has_video_msgs=True)) ) ) - if ( - user_type == 'business_account' - and self.user.employee.accepted - ): + if user_type == 'business_account' and self.user.employee.accepted: allowed_models = self.user.employee.parent_company.allowed_models else: allowed_models = None @@ -60,7 +60,7 @@ from ml_model.services.pulid import Pulid from ml_model.services.qwen import Qwen from ml_model.services.qwen_235B import Qwen_235B from ml_model.services.qwen_3_6 import Qwen_3_6 -from ml_model.services.qwen_3_7 import Qwen_3_7 +from ml_model.services.qwen_3_8 import Qwen_3_8 from ml_model.services.qwen_3_max_thinking import Qwen_3_Max_Thinking from ml_model.services.raifgpt import Raifgpt from ml_model.services.ray import Ray @@ -16,14 +16,15 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector import random + class Audio_Test_Model(SimpleService): - TOKENS_COST = Decimal('3') - - PLACEHOLDER_URL=[ + + PLACEHOLDER_URL = [ 'https://www.myinstants.com/media/sounds/saliut-eblany-batia-doma-billy-butcher-i-the-boys.mp3', 'https://www.myinstants.com/media/sounds/zdravstvuite-nichtozhnye-nishchie-smertnye.mp3', - 'https://www.myinstants.com/media/sounds/okh-zria-ia-tuda-polez.mp3'] # Позже убрать + 'https://www.myinstants.com/media/sounds/okh-zria-ia-tuda-polez.mp3', + ] # Позже убрать def calculate_price(self, num_audios: int = 1) -> Decimal: return self.TOKENS_COST * num_audios @@ -31,7 +32,7 @@ class Audio_Test_Model(SimpleService): @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: num_audios = info.get('num_audios', 1) - + return cls.TOKENS_COST * num_audios def save_results( @@ -55,43 +56,40 @@ class Audio_Test_Model(SimpleService): return Message.objects.bulk_create(messages) return messages - def make(self, input_message: Message, save: bool = True) -> list[Message]: - cau = input_message.info.get('cau') or self.PLACEHOLDER_URL[random.randint(0, 2)] # Позже убрать + cau = input_message.info.get('cau') or self.PLACEHOLDER_URL[random.randint(0, 2)] # Позже убрать num_audios = input_message.info.get('num_audios', 1) - - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.calculate_price(num_audios)): + + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.calculate_price(num_audios) + ): raise InsufficientBalance(balance, cost) - + start_time = time.time() - + audio_bytes = self._fetch_audio(cau) - + audios = [audio_bytes] * num_audios - + process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice(input_message.content_object.model, num_audios) - + msgs = self.save_results(input_message.content, process_time, audios, save) return msgs - def _fetch_audio(self, url: str): try: - response = requests.get( - url, - timeout=600 - ) + response = requests.get(url, timeout=600) response.raise_for_status() except requests.RequestException as exc: raise InvalidParameterError(f'Invalid audio URL: {exc}') - + kind = filetype.guess(response.content[:120]) - + if not kind: raise CorruptedFileError - + if not kind.mime.startswith('audio/'): raise InvalidParameterError('Audio format not supported') - - return response.content \ No newline at end of file + + return response.content @@ -275,7 +275,9 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): try: for proxy in Proxy.objects.all(): - json_data, predicted_input_tokens = self._build_payload(proxy, input_message, ctx, include_image_tool=False) + json_data, predicted_input_tokens = self._build_payload( + proxy, input_message, ctx, include_image_tool=False + ) model_name = ctx['model_name'] self.logger.info( f'Predicted input tokens (responses/input_tokens) для {model_name} - {predicted_input_tokens}' @@ -321,12 +323,7 @@ class Chatgpt(Chatgpt_4, StreamSimpleService, OpenAIStreamMixin): return result def _build_payload( - self, - proxy: Proxy, - input_message: Message, - ctx: dict[str, Any], - *, - include_image_tool: bool = True + self, proxy: Proxy, input_message: Message, ctx: dict[str, Any], *, include_image_tool: bool = True ) -> tuple[dict[str, Any], int]: if not ctx: info = input_message.info.copy() @@ -167,7 +167,10 @@ class Chatgpt_4(SimpleService): ) output_tokens = 0 self.assert_enough_balance( - input_tokens, image_size, model=self.llm.model_name, embedding_tokens=input_embedding_tokens + input_tokens, + image_size, + model=self.llm.model_name, + embedding_tokens=input_embedding_tokens, ) if model_name == 'gpt-oss-120b': system = chat_history.messages.pop(0) @@ -215,8 +218,9 @@ class Chatgpt_4(SimpleService): (data := response.json()) and data.get('choices') and ( - content := ','.join( - [choice['message']['content'] for choice in data.get('choices')]) + content := ','.join( + [choice['message']['content'] for choice in data.get('choices')] + ) ) ): input_tokens = response.json()['usage']['prompt_tokens'] @@ -242,7 +246,9 @@ class Chatgpt_4(SimpleService): ] elif file: if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -261,15 +267,17 @@ class Chatgpt_4(SimpleService): 'Используй системный промпт. Содержание файла: ' f'{"".join(text_chunks)}. Вопрос: {input_message.content}' ) - json_data = { - 'model': model_name, - 'messages': messages - } - input_tokens, output_tokens, response = self.call_openai_api(proxy=proxy, endpoint='chat/completions',json_data=json_data) + json_data = {'model': model_name, 'messages': messages} + input_tokens, output_tokens, response = self.call_openai_api( + proxy=proxy, endpoint='chat/completions', json_data=json_data + ) elif info.get('web_search', 'Отключено') != 'Отключено': system = chat_history.messages.pop(0) messages = [ - {'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', 'content': msg.content} + { + 'role': 'user' if isinstance(msg, HumanMessage) else 'assistant', + 'content': msg.content, + } for msg in chat_history.messages ] messages.insert(0, {'role': 'system', 'content': system.content}) @@ -285,7 +293,9 @@ class Chatgpt_4(SimpleService): ] elif file: if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -313,7 +323,9 @@ class Chatgpt_4(SimpleService): elif file: input_tokens = self.count_text_tokens([*chat_history.messages]) if sum([len(chunk.content) for chunk in chunks]) > 20_000: - document_name = chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + document_name = ( + chunks[0].content.partition(f':{chr(10)}')[2].split(f'{chr(10)}')[0][:100] + ) embedding_tokens, file_data = EmbeddingService.get_large_file_data( self.store.messages.first().pk, text_chunks, @@ -362,18 +374,16 @@ class Chatgpt_4(SimpleService): if output_tokens == 0: output_tokens = self.count_text_tokens([response]) - if ( - image - and normalized_image - and model_name != 'o3-mini' - ): + if image and normalized_image and model_name != 'o3-mini': self.logger.info(f'Input количество токенов БЕЗ картинки {model_name} - {input_tokens}') input_tokens += self.count_image_tokens(normalized_image.size, model_name) self.logger.info(f'Input количество токенов для {model_name} - {input_tokens}') self.logger.info(f'Output количество токенов для {model_name} - {output_tokens}') self.logger.info(f'Embedding количество токенов для {model_name} - {embedding_tokens}') - self.logger.info(f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}') + self.logger.info( + f'Общее количество токенов для {model_name} - {input_tokens + output_tokens + embedding_tokens}' + ) process_time = timedelta(seconds=time.time() - start_time) self.handle_invoice( @@ -382,7 +392,7 @@ class Chatgpt_4(SimpleService): output_tokens, self.llm.model_name, info, - embedding_tokens + embedding_tokens, ) msgs = self.save_results([response], process_time, save) http_client.close() @@ -412,22 +422,25 @@ class Chatgpt_4(SimpleService): for message in air_messages.iterator(5): air_message = [ AIMessage(content=message.content or '') - if message.from_model else - HumanMessage(content=message.content or '') + if message.from_model + else HumanMessage(content=message.content or '') ] if self.count_text_tokens(air_message) + tokens > token_limits[model_name]: break tokens += self.count_text_tokens(air_message) history.append(air_message[0]) memory = InMemoryChatMessageHistory() - memory.add_message(SystemMessage( - content=( - 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' - 'Be thorough and expansive.\n' - f'{self.NO_FILE_GENERATION_POLICY}' + memory.add_message( + SystemMessage( + content=( + 'Think step by step. Use full context. Prioritize depth, clarity, and justification. ' + 'Be thorough and expansive.\n' + f'{self.NO_FILE_GENERATION_POLICY}' + ) ) - )) - memory.add_message(SystemMessage( + ) + memory.add_message( + SystemMessage( content=( 'Отныне все ответы должны быть представлены как единая строка (str). Не использовать никаких ' 'структурированных форматов, таких как JSON, словари (dict) или списки (list). ' @@ -435,7 +448,8 @@ class Chatgpt_4(SimpleService): 'Не генерируй файлы и не предоставляй ссылки на скачивание файлов. ' 'Весь контент давай прямо в тексте ответа.' ) - )) + ) + ) memory.add_messages(list(reversed(history))) return memory @@ -454,8 +468,7 @@ class Chatgpt_4(SimpleService): input_cost = self.TOKENS_COST[model]['input'] * total_tokens if embedding_tokens > 0: input_cost += ( - embedding_tokens - * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] + embedding_tokens * self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] ) output_cost = self.TOKENS_COST[model]['output'] * output_tokens if input_cost + output_cost > balance: @@ -480,10 +493,7 @@ class Chatgpt_4(SimpleService): if info.get('code_interpreter', False): price += self.TOKENS_COST[model]['code_interpreter'] if embedding_tokens > 0: - price += ( - self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] - * embedding_tokens - ) + price += self.TOOLS_TOKEN_COSTS[self.EMBEDDING_MODEL_FOR_BILLING]['output'] * embedding_tokens return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def count_image_tokens(self, image_size: tuple, model_version: str = 'gpt-4o') -> int: @@ -591,19 +601,12 @@ class Chatgpt_4(SimpleService): headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, timeout=600, ) as client: - resp = client.post( - endpoint, - json=json_data - ) + resp = client.post(endpoint, json=json_data) if ( endpoint == 'chat/completions' and (data := resp.json()) and data.get('choices') - and ( - content := ','.join( - [choice['message']['content'] for choice in data.get('choices')] - ) - ) + and (content := ','.join([choice['message']['content'] for choice in data.get('choices')])) ): input_tokens = resp.json()['usage']['prompt_tokens'] output_tokens = resp.json()['usage']['completion_tokens'] @@ -92,7 +92,6 @@ class Chatgpt_5(Chatgpt_4): TOKEN_LIMITS = {key: 200_000 for key in TOKENS_COST.keys()} - def make( self, input_message: Message, @@ -32,6 +32,7 @@ from tools.chats.models import Chat from tools.copywrite.models import Copywrite from tools.public_api.models import APIStore + class Claude(SerperMixin, StreamSimpleService): """ Claude Service @@ -284,10 +285,7 @@ class Claude(SerperMixin, StreamSimpleService): if (chunks_length := sum(len(chunk) for chunk in chunks)) > 20_000: predict_embedding_tokens = len(chunks) * 2020 predicted_input_price += ( - ( - Decimal('210') - + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10') - ) + (Decimal('210') + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10')) / Decimal('2.7') * self.TOKENS_COST[version_slug]['input'] / Decimal('1_000_000') @@ -354,14 +352,10 @@ class Claude(SerperMixin, StreamSimpleService): } reasoning_effort = callback_data['reasoning']['effort'] reasoning_input_tokens = ( - {'low': 150, 'medium': 250}.get(reasoning_effort, 0) - if version_slug == 'claude-fable-5' - else 0 + {'low': 150, 'medium': 250}.get(reasoning_effort, 0) if version_slug == 'claude-fable-5' else 0 ) reasoning_output_reserve = ( - {'low': 300, 'medium': 500}.get(reasoning_effort, 0) - if version_slug == 'claude-fable-5' - else 0 + {'low': 300, 'medium': 500}.get(reasoning_effort, 0) if version_slug == 'claude-fable-5' else 0 ) estimated_input_tokens = ( Decimal( @@ -375,9 +369,7 @@ class Claude(SerperMixin, StreamSimpleService): + reasoning_input_tokens ) predicted_input_price += ( - estimated_input_tokens - * self.TOKENS_COST[version_slug]['input'] - / Decimal('1_000_000') + estimated_input_tokens * self.TOKENS_COST[version_slug]['input'] / Decimal('1_000_000') + predict_embedding_tokens * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] ) @@ -67,7 +67,7 @@ class Deepseek(SimpleService): messages = [ {'role': 'system', 'content': system_prompt}, *self.get_chat_history(), - {'role': 'user', 'content': input_message.content} + {'role': 'user', 'content': input_message.content}, ] start_time = time.time() @@ -83,15 +83,15 @@ class Deepseek(SimpleService): return msgs - - - def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: + def get_chat_history( + self, message_limit: int = 10, max_character_limit: int = 1500 + ) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit + 1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -117,4 +117,3 @@ class Deepseek(SimpleService): while character_length > max_character_limit: character_length -= len(memory.pop(0)['content']) return memory - @@ -39,7 +39,7 @@ class Dola_Seed(SimpleService): 'long_prompt': { 'input': Decimal('500'), 'output': Decimal('3000'), - } + }, }, 'seed-2-0-mini': { 'short_prompt': { @@ -49,7 +49,7 @@ class Dola_Seed(SimpleService): 'long_prompt': { 'input': Decimal('100'), 'output': Decimal('400'), - } + }, }, # 1M tokens } @@ -124,11 +124,7 @@ class Dola_Seed(SimpleService): raise GenerationException data = json.loads(result.stdout) video_stream = next( - ( - stream - for stream in data.get('streams', []) - if stream.get('codec_type') == 'video' - ), + (stream for stream in data.get('streams', []) if stream.get('codec_type') == 'video'), None, ) if not video_stream: @@ -136,9 +132,7 @@ class Dola_Seed(SimpleService): duration = float(data.get('format', {}).get('duration') or 0) if duration <= 0: raise GenerationException - fps = cls._parse_video_fps( - video_stream.get('r_frame_rate') or video_stream.get('avg_frame_rate') - ) + fps = cls._parse_video_fps(video_stream.get('r_frame_rate') or video_stream.get('avg_frame_rate')) return int(video_stream['width']), int(video_stream['height']), duration, fps @staticmethod @@ -158,7 +152,7 @@ class Dola_Seed(SimpleService): def calculate_price(self, version: str, input_tokens: int, output_tokens: int) -> Decimal: price_map = self.TOKENS_COST[version] - prompt_type = "short_prompt" if input_tokens <= 128_000 else "long_prompt" + prompt_type = 'short_prompt' if input_tokens <= 128_000 else 'long_prompt' price = ( input_tokens * price_map[prompt_type]['input'] / 1_000_000 + output_tokens * price_map[prompt_type]['output'] / 1_000_000 @@ -179,9 +173,7 @@ class Dola_Seed(SimpleService): return msgs - def _prepare_data( - self, input_message: Message - ) -> tuple[str, dict[str, Any], list[dict[str, Any]]]: + def _prepare_data(self, input_message: Message) -> tuple[str, dict[str, Any], list[dict[str, Any]]]: info = input_message.info.copy() version = info.pop('version', None) if version is None or version not in self.TOKENS_COST: @@ -207,9 +199,7 @@ class Dola_Seed(SimpleService): with Image.open(BytesIO(file_bytes)) as normalized_image: image_width, image_height = normalized_image.size elif attachment_type == 'video_url': - video_width, video_height, video_duration, video_fps = self._get_video_metadata( - file_bytes - ) + video_width, video_height, video_duration, video_fps = self._get_video_metadata(file_bytes) content.append({'type': attachment_type, attachment_type: {'url': file.url}}) messages.append({'role': 'user', 'content': content}) api_model = self.VERSION_MAPPING[version] @@ -221,9 +211,7 @@ class Dola_Seed(SimpleService): else: texts.append( ''.join( - str(item.get('text') or '') - for item in message_content - if isinstance(item, dict) + str(item.get('text') or '') for item in message_content if isinstance(item, dict) ) ) predicted_input_tokens = BytedanceModelArkAdapter.batch_tokenize(api_model, texts) @@ -274,7 +262,7 @@ class Dola_Seed(SimpleService): callback_data=callback_data, content_type=BytedanceContentType.CHAT, messages=messages, - include_reasoning=True + include_reasoning=True, ) process_time = timedelta(seconds=(time.time() - start_time)) @@ -327,13 +315,9 @@ class Dola_Seed(SimpleService): input_text_parts.append(content) else: input_text_parts.extend( - str(item.get('text') or '') - for item in content - if isinstance(item, dict) + str(item.get('text') or '') for item in content if isinstance(item, dict) ) - input_tokens = BytedanceModelArkAdapter.tokenize( - model, ''.join(input_text_parts) - ) + input_tokens = BytedanceModelArkAdapter.tokenize(model, ''.join(input_text_parts)) output_tokens = BytedanceModelArkAdapter.tokenize(model, result) process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( @@ -355,7 +339,7 @@ class Dola_Seed(SimpleService): reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit + 1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -72,10 +72,7 @@ class Elevenlabs(SimpleService): raw_file_extension = kind.extension file_extension = file_service.get_file_extension(raw_file_extension, file_bytes) if file_extension in ('pdf', 'doc', 'docx'): - raw_text = ( - file_service.get_file_data(file_extension, file_bytes) - .replace('\n', ' ') - ) + raw_text = file_service.get_file_data(file_extension, file_bytes).replace('\n', ' ') else: raise FileExtensionNotSupported(['PDF', 'DOC', 'DOCX']) max_affordable_chars = max( @@ -61,6 +61,21 @@ class Flux(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() + version = 'flux-schnell' + user_prompt = self.translate_prompt(input_message.content) + callback_data = dict( + { + 'prompt': f'{user_prompt}\n{self.OPTIMIZATION_PROMPT}', + 'go_fast': False, + 'output_quality': 100, + **input_message.info, + } + ) + runner = replicate_run( + f'{self._CALLBACK_BASE}{version}', + callback_data, + ) + images = runner if isinstance(runner, list) else [runner] output_megapixels = 1 callback_data = { 'prompt': input_message.content, @@ -12,8 +12,12 @@ from django.core.files import File from django.core.files.images import get_image_dimensions from messages.models import Message -from ml_model.exceptions import PredictionInterruptedError, RequestBlocked, GenerationException, \ - FileExtensionNotSupported +from ml_model.exceptions import ( + PredictionInterruptedError, + RequestBlocked, + GenerationException, + FileExtensionNotSupported, +) from ml_model.exceptions import ModelVersionNotAvailable from ml_model.services.base import SimpleService from ml_model.tasks import replicate_run @@ -39,9 +43,9 @@ class Flux_2(SimpleService): def calculate_price(self, version: str, input_mp: int, output_mp: int) -> Decimal: version_price = self.TOKENS_COST[version] price = ( - version_price.get('run', Decimal('0')) + - version_price['input_mp'] * input_mp + - version_price['output_mp'] * output_mp + version_price.get('run', Decimal('0')) + + version_price['input_mp'] * input_mp + + version_price['output_mp'] * output_mp ) return price.quantize(Decimal('0.1'), rounding='ROUND_UP') @@ -73,7 +77,7 @@ class Flux_2(SimpleService): raise ModelVersionNotAvailable(version, self.TOKENS_COST) width = input_message.info.pop('width', 1024) height = input_message.info.pop('height', 1024) - output_mp = math.ceil((width*height) / 1_000_000) + output_mp = math.ceil((width * height) / 1_000_000) callback_data = { 'prompt': input_message.content, 'aspect_ratio': 'custom', @@ -95,12 +99,14 @@ class Flux_2(SimpleService): with BytesIO() as buf: normalized_image.save(buf, format=format) file_width, file_height = get_image_dimensions(buf) - input_mp = math.ceil((file_width*file_height) / 1_000_000) + input_mp = math.ceil((file_width * file_height) / 1_000_000) image = f'data:image/{format};base64,{base64.b64encode(buf.getvalue()).decode("utf-8")}' normalized_image.close() callback_data.update({'input_images': [image]}) images = [replicate_run(f'black-forest-labs/{version}', callback_data)] process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice(input_message.content_object.model, version=version, input_mp=input_mp, output_mp=output_mp) + self.handle_invoice( + input_message.content_object.model, version=version, input_mp=input_mp, output_mp=output_mp + ) msgs = self.save_results(input_message.content, images, process_time, save) return msgs @@ -65,7 +65,7 @@ class Fluxpulid(SimpleService): translated_prompt = self.translate_prompt(input_message.content) callback_data = dict( { - 'prompt': f"{translated_prompt}\n{self.OPTIMIZATION_PROMPT}", + 'prompt': f'{translated_prompt}\n{self.OPTIMIZATION_PROMPT}', 'main_face_image': BytesIO(input_message.file.read()), 'output_quality': 100, 'output_format': 'png', @@ -24,7 +24,6 @@ from tools.copywrite.models import Copywrite from tools.public_api.models import APIStore - class Gemini_3_1(StreamSimpleService): TOKENS_COST = { 'gemini-3.1-pro-preview': { @@ -36,14 +35,16 @@ class Gemini_3_1(StreamSimpleService): 'input': Decimal('75'), 'output': Decimal('450'), 'highest_prices': {'input': Decimal('75'), 'output': Decimal('450')}, - } + }, } TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} SUPPORTED_EXTENSIONS = ['PDF', 'DOC', 'DOCX', 'XLSX', 'JPG', 'JPEG', 'PNG', 'WEBP'] - def calculate_price(self, version: str, input_tokens: int, output_tokens: int, embedding_tokens: int) -> Decimal: + def calculate_price( + self, version: str, input_tokens: int, output_tokens: int, embedding_tokens: int + ) -> Decimal: price_map = self.TOKENS_COST[version] if input_tokens >= 200_000 or output_tokens >= 200_000: price = ( @@ -177,9 +178,7 @@ class Gemini_3_1(StreamSimpleService): character_length -= len(memory.pop(0)['content']) return memory - def _prepare_messages( - self, input_message: Message - ) -> tuple[list[dict[str, str | list]], int]: + def _prepare_messages(self, input_message: Message) -> tuple[list[dict[str, str | list]], int]: messages = self.get_chat_history() messages.insert( 0, @@ -129,7 +129,7 @@ class Gptimage(SimpleService): for proxy in Proxy.objects.all(): moderation = 'low' if self.store.user.account_type == 'regular' else 'auto' json_data = { - 'prompt': f"{input_message.content}\n{self.OPTIMIZATION_PROMPT}", + 'prompt': f'{input_message.content}\n{self.OPTIMIZATION_PROMPT}', 'model': 'gpt-image-2', 'n': 1, 'quality': quality, @@ -32,6 +32,7 @@ class Grok(SerperMixin, StreamSimpleService): TOKENS_COST = { 'grok-4.3': {'input': Decimal('875'), 'output': Decimal('1750'), 'coefficient': Decimal('5')}, 'grok-4.5': {'input': Decimal('1000'), 'output': Decimal('3000'), 'coefficient': Decimal('5')}, + 'grok-4.6': {'input': Decimal('600'), 'output': Decimal('1800'), 'coefficient': Decimal('5')}, } TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} @@ -194,10 +195,7 @@ class Grok(SerperMixin, StreamSimpleService): if (chunks_length := sum(len(chunk) for chunk in chunks)) > 20_000: predict_embedding_tokens = len(chunks) * 2020 predicted_input_price += ( - ( - Decimal('210') - + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10') - ) + (Decimal('210') + Decimal(chunks_length) / Decimal(len(chunks)) * Decimal('10')) / Decimal('2.0') * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') @@ -251,21 +249,14 @@ class Grok(SerperMixin, StreamSimpleService): if image: predicted_image_tokens = min((image_width * image_height + 999) // 1000, 2500) predicted_input_price += ( - Decimal(predicted_image_tokens) - * self.TOKENS_COST[version]['input'] - / Decimal('1_000_000') + Decimal(predicted_image_tokens) * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') ) - estimated_input_tokens = ( - Decimal( - sum( - len(message['content']) if isinstance(message['content'], str) else 0 - for message in messages - ) - + (len(input_message.content) if image else 0) + estimated_input_tokens = Decimal( + sum( + len(message['content']) if isinstance(message['content'], str) else 0 for message in messages ) - / Decimal('2.0') - + (150 if is_free_plan else 250) - ) + + (len(input_message.content) if image else 0) + ) / Decimal('2.0') + (150 if is_free_plan else 250) predicted_input_price += ( estimated_input_tokens * self.TOKENS_COST[version]['input'] / Decimal('1_000_000') + predict_embedding_tokens * self.TOOLS_TOKEN_COSTS['text-embedding-3-small']['output'] @@ -31,7 +31,6 @@ class Grok_4_1_Fast(SimpleService): MAX_PIXELS = 178956970 - def calculate_price(self, input_tokens: int, output_tokens: int, embedding_tokens: int) -> Decimal: price = ( input_tokens * self.TOKENS_COST['input'] / 1_000_000 @@ -115,10 +114,7 @@ class Grok_4_1_Fast(SimpleService): raise ImageTooLargeError(self.MAX_PIXELS) mime = kind.mime if kind else 'application/octet-stream' - image_url = ( - f'data:{mime};base64,' - f'{base64.b64encode(file_bytes).decode("utf-8")}' - ) + image_url = f'data:{mime};base64,{base64.b64encode(file_bytes).decode("utf-8")}' messages[-1]['content'] = [ {'type': 'text', 'text': input_message.content}, @@ -21,14 +21,8 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Hailuo(SimpleService): TOKENS_COST = { - 'hailuo-2.3': { - '768p': Decimal('84'), - '1080p': Decimal('147') - }, - 'hailuo-2.3-fast': { - '768p': Decimal('57'), - '1080p': Decimal('99') - } + 'hailuo-2.3': {'768p': Decimal('84'), '1080p': Decimal('147')}, + 'hailuo-2.3-fast': {'768p': Decimal('57'), '1080p': Decimal('99')}, } def calculate_price(self, version: str, resolution: str) -> Decimal: @@ -58,12 +52,15 @@ class Hailuo(SimpleService): if version is None or version not in self.TOKENS_COST: raise ModelVersionNotAvailable(version, self.TOKENS_COST) resolution = input_message.info.get('resolution', '768p') - if ( - (balance := PaymentPlanSelector(self.store.user).get_current_balance()) - < (cost := self.TOKENS_COST[version][resolution]) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.TOKENS_COST[version][resolution] ): raise InsufficientBalance(balance, cost) - callback_data = {'prompt': self.translate_prompt(input_message.content), 'duration': 6, **input_message.info} + callback_data = { + 'prompt': self.translate_prompt(input_message.content), + 'duration': 6, + **input_message.info, + } if input_message.file: kind = filetype.guess(input_message.file.read(20)) mime = kind.mime if kind else 'application/octet-stream' @@ -72,10 +69,7 @@ class Hailuo(SimpleService): input_message.file.close() callback_data.update({'first_frame_image': image}) start_time = time.time() - video = replicate_run( - f'minimax/{version}', - callback_data - ) + video = replicate_run(f'minimax/{version}', callback_data) process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice(input_message.content_object.model, version=version, resolution=resolution) msgs = self.save_results(input_message.content, process_time, video, save) @@ -68,10 +68,9 @@ class Ideogram(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: start_time = time.time() version = 'ideogram-v3-turbo' - if ( - input_message.info.get('style_preset', 'None') != 'None' - and input_message.info.get('style_type', 'None') not in ('None', 'Auto', 'General') - ): + if input_message.info.get('style_preset', 'None') != 'None' and input_message.info.get( + 'style_type', 'None' + ) not in ('None', 'Auto', 'General'): raise InvalidStyleCombinationError callback_data = dict( { @@ -47,11 +47,15 @@ class Kling(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: mode = input_message.info.pop('mode', 'standard') duration = input_message.info.get('duration', 5) - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.TOKENS_COST[mode] * duration): + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.TOKENS_COST[mode] * duration + ): raise InsufficientBalance(balance, cost) if not input_message.file: raise FileNotProvided('Image') - callback_data = dict({'prompt': self.translate_prompt(input_message.content), 'mode': mode, **input_message.info}) + callback_data = dict( + {'prompt': self.translate_prompt(input_message.content), 'mode': mode, **input_message.info} + ) kind = filetype.guess(input_message.file.read(20)) mime = kind.mime if kind else 'application/octet-stream' input_message.file.seek(0) @@ -26,23 +26,20 @@ class Llama(SimpleService): """ TOKENS_COST = { - 'llama-3.3-70b-instruct': { - 'input': Decimal('84'), - 'output': Decimal('84') - }, # 1M tokens + 'llama-3.3-70b-instruct': {'input': Decimal('84'), 'output': Decimal('84')}, # 1M tokens 'llama-4-maverick': { 'input': Decimal('180'), 'output': Decimal('180'), - 'input_imgs': Decimal('200.52') + 'input_imgs': Decimal('200.52'), }, # 1M tokens } def calculate_price( - self, version: str, input_tokens: int, output_tokens: int, image: FieldFile + self, version: str, input_tokens: int, output_tokens: int, image: FieldFile ) -> Decimal: price_map = self.TOKENS_COST[version.split('/')[1]] price = ( - input_tokens * price_map['input'] / 1_000_000 + output_tokens * price_map['output'] / 1_000_000 + input_tokens * price_map['input'] / 1_000_000 + output_tokens * price_map['output'] / 1_000_000 ) if image: price += price_map['input_imgs'] / 1_000 @@ -97,13 +94,15 @@ class Llama(SimpleService): msgs = self.save_results(result[0], process_time) return msgs - def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: + def get_chat_history( + self, message_limit: int = 10, max_character_limit: int = 1500 + ) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit + 1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -65,7 +65,9 @@ class Ltx(SimpleService): 'prompt': input_message.content, 'resolution': resolution, 'duration': duration, - 'camera_motion': camera_motion[input_message.info.pop('camera_motion', 'Без движения камеры')], + 'camera_motion': camera_motion[ + input_message.info.pop('camera_motion', 'Без движения камеры') + ], **input_message.info, } ) @@ -81,7 +81,7 @@ class Midjourney(SimpleService): OUTPUT: Return ONLY a single concise image-generation prompt describing the final scene. """ - callback_data = dict(prompt=activation_prompt, prompt_optimizer=False,**input_message.info) + callback_data = dict(prompt=activation_prompt, prompt_optimizer=False, **input_message.info) results = replicate_run(self._CALLBACK, callback_data) process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice(input_message.content_object.model, input_message=input_message) @@ -43,7 +43,9 @@ class Minimaxvideo(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: version = 'video-01' - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[version]: + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[ + version + ]: raise InsufficientBalance(balance, self.TOKENS_COST[version]) callback_data = dict({'prompt': input_message.content, **input_message.info}) if input_message.file: @@ -29,7 +29,7 @@ class MinIOService: endpoint=settings.MINIO_ENDPOINT, access_key=settings.MINIO_ACCESS_KEY, secret_key=settings.MINIO_SECRET_KEY, - secure=settings.MINIO_USE_HTTPS + secure=settings.MINIO_USE_HTTPS, ) def put_object(self, obj: BytesIO, filename: str, dest: str) -> str: @@ -23,23 +23,19 @@ class Perplexity(SimpleService): """ TOKENS_COST = { - 'sonar': { - 'input': Decimal('300'), - 'output': Decimal('300'), - 'search': Decimal('1500') - }, # 1M tokens + 'sonar': {'input': Decimal('300'), 'output': Decimal('300'), 'search': Decimal('1500')}, # 1M tokens 'sonar-deep-research': { 'input': Decimal('600'), # 1M tokens - 'output': Decimal('2400'), # 1M tokens - 'citation': Decimal('600'), # 1M tokens - 'search': Decimal('1500'), # 1K queries - 'reasoning': Decimal('900') # 1M tokens + 'output': Decimal('2400'), # 1M tokens + 'citation': Decimal('600'), # 1M tokens + 'search': Decimal('1500'), # 1K queries + 'reasoning': Decimal('900'), # 1M tokens }, 'sonar-pro-search': { 'input': Decimal('900'), # 1M tokens 'output': Decimal('4500'), # 1M tokens 'search': Decimal('5400'), # 1K queries - } + }, } def calculate_price(self, version: str, input_tokens: int, output_tokens: int) -> Decimal: @@ -78,9 +74,11 @@ class Perplexity(SimpleService): try: for proxy in Proxy.objects.all(): response = httpx.post( - url='https://openrouter.ai/api/v1/chat/completions', headers={'Authorization': f'Bearer {settings.OPENROUTER_API_KEY}'}, - proxy=f'{proxy.protocol}://{proxy.address}', timeout=600, - json={'model': version, 'messages': messages, **callback_data} + url='https://openrouter.ai/api/v1/chat/completions', + headers={'Authorization': f'Bearer {settings.OPENROUTER_API_KEY}'}, + proxy=f'{proxy.protocol}://{proxy.address}', + timeout=600, + json={'model': version, 'messages': messages, **callback_data}, ) if response.status_code not in (200, 201): raise @@ -91,17 +89,25 @@ class Perplexity(SimpleService): content = ( re.sub( r'\[(\d+)\]', - lambda m: f' [[{m.group(1)}]]({str(annotations[int(m.group(1)) - 1]["url_citation"]["url"])})', - content + lambda m: ( + f' [[{m.group(1)}]]({str(annotations[int(m.group(1)) - 1]["url_citation"]["url"])})' + ), + content, ) - + f'\n\n### Ресурсы:\n{"\n".join( - [ - f'{num}. {a["url_citation"]["title"]} ({a["url_citation"]["url"]})' - for num, a in enumerate(annotations, start=1) - ] - )}' + + f'\n\n### Ресурсы:\n{ + "\n".join( + [ + f"{num}. {a['url_citation']['title']} ({a['url_citation']['url']})" + for num, a in enumerate(annotations, start=1) + ] + ) + }' ) - result = [content, response['usage']['prompt_tokens'], response['usage']['completion_tokens']] + result = [ + content, + response['usage']['prompt_tokens'], + response['usage']['completion_tokens'], + ] except Exception: raise Exception(f'No answer from Perplexity, please retry later') process_time = timedelta(seconds=(time.time() - start_time)) @@ -114,13 +120,15 @@ class Perplexity(SimpleService): msgs = self.save_results(result[0], process_time) return msgs - def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: + def get_chat_history( + self, message_limit: int = 10, max_character_limit: int = 1500 + ) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit + 1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -146,4 +154,4 @@ class Perplexity(SimpleService): while character_length > max_character_limit: character_length -= len(memory.pop(0)['content']) - return memory \ No newline at end of file + return memory @@ -19,14 +19,8 @@ class Qwen(SimpleService): """ TOKENS_COST = { - 'qwq-32b': { - 'input': Decimal('45'), - 'output': Decimal('60') - }, # 1M tokens - 'qwq-32b:free': { - 'input': Decimal('0'), - 'output': Decimal('0') - }, # 1M tokens + 'qwq-32b': {'input': Decimal('45'), 'output': Decimal('60')}, # 1M tokens + 'qwq-32b:free': {'input': Decimal('0'), 'output': Decimal('0')}, # 1M tokens } def calculate_price(self, version: str, input_tokens: int, output_tokens: int) -> Decimal: @@ -73,8 +67,8 @@ class Qwen(SimpleService): 'На бытовые, нейтральные или социальные вопросы (например: "что делаешь?", "как дела?") можно отвечать\n' 'Все размышления и логика перед ответом — на русском. Другой язык разрешён только в цитатах или если вопрос явно на другом языке.\n"' 'Не пересматривай прошлые примеры ответов, оценивай только текущий запрос. Не повторяй одни и те же выводы многократно.' - ) - } + ), + }, ) messages.append({'role': 'user', 'content': input_message.content}) result = openrouter_run(version, messages, callback_data, 'Qwen') @@ -88,13 +82,15 @@ class Qwen(SimpleService): msgs = self.save_results(result[0], process_time) return msgs - def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: + def get_chat_history( + self, message_limit: int = 10, max_character_limit: int = 1500 + ) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit+1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -19,18 +19,9 @@ class Qwen_235B(SimpleService): """ TOKENS_COST = { - 'qwen3-235b-a22b-thinking-2507': { - 'input': Decimal('33'), - 'output': Decimal('180') - }, # 1M tokens - 'qwen3-235b-a22b-2507': { - 'input': Decimal('24'), - 'output': Decimal('165') - }, # 1M tokens - 'qwen3-235b-a22b:free': { - 'input': Decimal('0'), - 'output': Decimal('0') - }, # 1M tokes + 'qwen3-235b-a22b-thinking-2507': {'input': Decimal('33'), 'output': Decimal('180')}, # 1M tokens + 'qwen3-235b-a22b-2507': {'input': Decimal('24'), 'output': Decimal('165')}, # 1M tokens + 'qwen3-235b-a22b:free': {'input': Decimal('0'), 'output': Decimal('0')}, # 1M tokes } def calculate_price(self, version: str, input_tokens: int, output_tokens: int) -> Decimal: @@ -77,8 +68,8 @@ class Qwen_235B(SimpleService): 'На бытовые, нейтральные или социальные вопросы (например: "что делаешь?", "как дела?") можно отвечать\n' 'Все размышления и логика перед ответом — на русском. Другой язык разрешён только в цитатах или если вопрос явно на другом языке.\n"' 'Не пересматривай прошлые примеры ответов, оценивай только текущий запрос. Не повторяй одни и те же выводы многократно.' - ) - } + ), + }, ) messages.append({'role': 'user', 'content': input_message.content}) result = openrouter_run(version, messages, callback_data, 'Qwen') @@ -92,13 +83,15 @@ class Qwen_235B(SimpleService): msgs = self.save_results(result[0], process_time) return msgs - def get_chat_history(self, message_limit: int = 10, max_character_limit: int = 1500) -> list[dict[str, str | list]]: + def get_chat_history( + self, message_limit: int = 10, max_character_limit: int = 1500 + ) -> list[dict[str, str | list]]: if isinstance(self.store, Chat): air_messages = list( reversed( Message.objects.filter( chats_chats_messages=self.store, is_deleted=False, is_sent=True - ).order_by('-created_at')[1:message_limit+1] + ).order_by('-created_at')[1 : message_limit + 1] ) ) elif isinstance(self.store, APIStore): @@ -162,4 +162,4 @@ class Qwen_3_6(SimpleService): while character_length > max_character_limit: character_length -= len(memory.pop(0)['content']) - return memory \ No newline at end of file + return memory @@ -22,15 +22,16 @@ from tools.copywrite.models import Copywrite from tools.public_api.models import APIStore -class Qwen_3_7(StreamSimpleService): +class Qwen_3_8(StreamSimpleService): COEFFICIENT = Decimal('300.0') TOKENS_COST = { + 'qwen3.8-max': {'input': Decimal('600'), 'output': Decimal('1800')}, # $2 / $6 'qwen3.7-max': {'input': Decimal('442.5'), 'output': Decimal('1327.5')}, # $1.475 / $4.425 'qwen3.7-plus': {'input': Decimal('96'), 'output': Decimal('384')}, # $0.32 / $1.28 } - MAX_OUTPUT_TOKENS = 30_000 + MAX_OUTPUT_TOKENS = 131_072 // 2 TOOLS_TOKEN_COSTS = {'text-embedding-3-small': {'output': Decimal('0.00001')}} @@ -80,7 +81,7 @@ class Qwen_3_7(StreamSimpleService): version_slug, model_slug, callback_data, messages, embedding_tokens = self._prepare_data( input_message ) - result = openrouter_run(model_slug, messages, callback_data, 'Qwen 3.7') + result = openrouter_run(model_slug, messages, callback_data, 'Qwen 3.8') process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice( input_message.content_object.model, @@ -237,7 +238,6 @@ class Qwen_3_7(StreamSimpleService): def _estimate_cost(self, version_slug: str, input_tokens: int, output_tokens: int) -> float: price_map = self.TOKENS_COST[version_slug] price = ( - input_tokens * price_map['input'] / 1_000_000 - + output_tokens * price_map['output'] / 1_000_000 + input_tokens * price_map['input'] / 1_000_000 + output_tokens * price_map['output'] / 1_000_000 ) return float(price / self.COEFFICIENT) @@ -17,11 +17,13 @@ class Qwen_3_Max_Thinking(SimpleService): } def calculate_price(self, input_tokens: int, output_tokens: int) -> Decimal: - price = input_tokens * ( - self.TOKENS_COST['input']['default' if input_tokens <= 32_000 else 'high'] / 1_000_000 - ) + output_tokens * ( - self.TOKENS_COST['output']['default' if input_tokens <= 32_000 else 'high'] / 1_000_000 - ) + Decimal('2') + price = ( + input_tokens + * (self.TOKENS_COST['input']['default' if input_tokens <= 32_000 else 'high'] / 1_000_000) + + output_tokens + * (self.TOKENS_COST['output']['default' if input_tokens <= 32_000 else 'high'] / 1_000_000) + + Decimal('2') + ) return price.quantize(Decimal('0.1'), rounding='ROUND_UP') def save_results(self, content: str, time: timedelta, save: bool = True) -> list[Message]: @@ -88,4 +90,4 @@ class Qwen_3_Max_Thinking(SimpleService): while character_length > max_character_limit: character_length -= len(memory.pop(0)['content']) - return memory \ No newline at end of file + return memory @@ -212,10 +212,10 @@ class Raifgpt(Chatgpt_4): input = [ SystemMessage(content=user_system_prompt), HumanMessage( - content=( - 'Используй системный промпт. Содержание файла: ' - f'{"".join(chunk.content for chunk in chunks)}. Вопрос: {input_message.content}' - ) + content=( + 'Используй системный промпт. Содержание файла: ' + f'{"".join(chunk.content for chunk in chunks)}. Вопрос: {input_message.content}' + ) ), ] input_tokens += self.count_text_tokens(input) @@ -263,11 +263,11 @@ class Raifgpt(Chatgpt_4): image_count = 0 doc = None try: - doc = fitz.open(stream=pdf_data, filetype="pdf") + doc = fitz.open(stream=pdf_data, filetype='pdf') raw_texts = {} pages_with_image = [] for page_num, page in enumerate(doc): - text = page.get_text("text") + text = page.get_text('text') if text: raw_texts[page_num] = text if page.get_images(): @@ -276,28 +276,28 @@ class Raifgpt(Chatgpt_4): if doc is not None: doc.close() fitz.TOOLS.store_shrink(100) - all_text = "\n".join(raw_texts.get(i, "") for i in sorted(raw_texts)) - return all_text if all_text.strip() else "Не удалось извлечь текст из PDF" + all_text = '\n'.join(raw_texts.get(i, '') for i in sorted(raw_texts)) + return all_text if all_text.strip() else 'Не удалось извлечь текст из PDF' except Exception as e: if doc is not None: doc.close() - return f"Ошибка при чтении PDF: {e}" + return f'Ошибка при чтении PDF: {e}' try: batch_images = [] page_index_map = [] headers = { - "Authorization": f"Api-Key {settings.YANDEX_CLOUD_API_KEY}", - "Content-Type": "application/json" + 'Authorization': f'Api-Key {settings.YANDEX_CLOUD_API_KEY}', + 'Content-Type': 'application/json', } for page_num in pages_with_image: try: page = doc.load_page(page_num) image_count += 1 pix = page.get_pixmap(dpi=150, alpha=False) - img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + img = Image.frombytes('RGB', [pix.width, pix.height], pix.samples) img.info = {} buffer = BytesIO() - img.save(buffer, format="JPEG", quality=60, optimize=True) + img.save(buffer, format='JPEG', quality=60, optimize=True) buffer.seek(0) if buffer.getbuffer().nbytes < max_batch_size: batch_images.append(buffer) @@ -308,7 +308,7 @@ class Raifgpt(Chatgpt_4): if doc is not None: doc.close() fitz.TOOLS.store_shrink(100) - return "Не удалось собрать изображения из PDF." + return 'Не удалось собрать изображения из PDF.' batches = [] current_batch = [] current_pages = [] @@ -326,56 +326,62 @@ class Raifgpt(Chatgpt_4): ocr_texts = {} for batch, pages in batches: body = { - "folderId": settings.YANDEX_CLOUD_ID, - "analyze_specs": [{ - "content": base64.b64encode(buf.getvalue()).decode(), - "features": [{ - "type": "TEXT_DETECTION", - "text_detection_config": {"language_codes": ["*"]} - }] - } for buf in batch] + 'folderId': settings.YANDEX_CLOUD_ID, + 'analyze_specs': [ + { + 'content': base64.b64encode(buf.getvalue()).decode(), + 'features': [ + { + 'type': 'TEXT_DETECTION', + 'text_detection_config': {'language_codes': ['*']}, + } + ], + } + for buf in batch + ], } resp = requests.post( - "https://vision.api.cloud.yandex.net/vision/v1/batchAnalyze", - headers=headers, json=body, timeout=60 + 'https://vision.api.cloud.yandex.net/vision/v1/batchAnalyze', + headers=headers, + json=body, + timeout=60, ) if resp.status_code != 200: continue result = resp.json() - for i, spec_result in enumerate(result.get("results", [])): + for i, spec_result in enumerate(result.get('results', [])): page_text = [] - for res in spec_result.get("results", []): - for page in res.get("textDetection", {}).get("pages", []): + for res in spec_result.get('results', []): + for page in res.get('textDetection', {}).get('pages', []): for block in page.get('blocks', []): for line in block.get('lines', []): - line_text = " ".join( + line_text = ' '.join( word.get('text', '') for word in line.get('words', []) ) if line_text: page_text.append(line_text) - ocr_texts[pages[i]] = "\n".join(page_text) + ocr_texts[pages[i]] = '\n'.join(page_text) if doc is not None: doc.close() fitz.TOOLS.store_shrink(100) all_pages = sorted(set(raw_texts) | set(ocr_texts)) - final_text = "\n\n".join( - f"{raw_texts.get(pn, '')}\n{ocr_texts.get(pn, '')}".strip() - for pn in all_pages + final_text = '\n\n'.join( + f'{raw_texts.get(pn, "")}\n{ocr_texts.get(pn, "")}'.strip() for pn in all_pages ) self.image_count = image_count - return final_text.strip() or "Не удалось распознать текст" + return final_text.strip() or 'Не удалось распознать текст' except Exception as e: if doc is not None: doc.close() - return f"Не удалось обработать файл: {e}" + return f'Не удалось обработать файл: {e}' def make_embeddings_prompt(self, document_name: str, section_texts: List[str], question: str) -> str: - ''' + """ A method for making a prompt using found embeddings :param document_name: name of the loaded document :param section_texts: list of sections' contents :param question: user question - ''' + """ return f"""Ты — аналитик данных моей компании. Отвечай исключительно на основе предоставленного ниже контекста. НЕЛЬЗЯ использовать внешние знания или домыслы. @@ -394,7 +400,9 @@ class Raifgpt(Chatgpt_4): Сформируй ПОЛНЫЙ и СТРУКТУРИРОВАННЫЙ ответ, даже если доступные данные частичные. """ - def get_anchor_embedding(self, client: httpx.Client, content: str, anchor: str) -> Tuple[List[float], int, str]: + def get_anchor_embedding( + self, client: httpx.Client, content: str, anchor: str + ) -> Tuple[List[float], int, str]: """ A method for converting raw text (anchor content) into embeddings using OpenAI API request @@ -434,4 +442,3 @@ class Raifgpt(Chatgpt_4): return f'Это текст, извлечённый из загруженного WORD-файла:\n{text}' else: return 'Файл пуст или содержит изображения, из которых невозможно извлечь текст.' - @@ -50,11 +50,11 @@ class Reve(SimpleService): # return self.PRICE[type].quantize(Decimal('0.1'), rounding='ROUND_UP') def save_results( - self, - prompt: str, - image: str, - time: timedelta, - save: bool = True, + self, + prompt: str, + image: str, + time: timedelta, + save: bool = True, ) -> list[Message]: messages: list[Message] = [] messages.append( @@ -19,7 +19,6 @@ from payments.selectors.payment_plan_selector import PaymentPlanSelector class Runway(SimpleService): - TOKENS_COST = { 'gen4-turbo': Decimal('15'), } @@ -50,9 +49,8 @@ class Runway(SimpleService): version = 'gen4-turbo' duration = input_message.info.get('duration', 5) file = input_message.file - if ( - (balance := PaymentPlanSelector(self.store.user).get_current_balance()) - < (cost := self.calculate_price(version, duration)) + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.calculate_price(version, duration) ): raise InsufficientBalance(balance, cost) if not file: @@ -65,7 +63,7 @@ class Runway(SimpleService): callback_data = { 'prompt': self.translate_prompt(input_message.content), **input_message.info, - 'image': media + 'image': media, } start_time = time.time() video = replicate_run(f'runwayml/{version}', callback_data) @@ -205,4 +205,4 @@ class Seedance_2_Dreamina(SimpleService): text=True, check=True, ) - return float(json.loads(out.stdout)["format"]["duration"]) \ No newline at end of file + return float(json.loads(out.stdout)['format']['duration']) @@ -18,9 +18,7 @@ from ml_model.services.base import SimpleService from poller.models import Proxy - class Sora(SimpleService): - TOKENS_COST = { 'sora-2': Decimal('30'), 'sora-2-pro': Decimal('90'), @@ -48,7 +46,7 @@ class Sora(SimpleService): return Message.objects.bulk_create([msg]) return [msg] - def make(self, input_message: "Message", save: bool = True) -> list["Message"]: + def make(self, input_message: 'Message', save: bool = True) -> list['Message']: if input_message.content: for proxy in Proxy.objects.all(): version = input_message.info.pop('version', None) @@ -59,7 +57,7 @@ class Sora(SimpleService): 'prompt': input_message.content, 'model': version, 'seconds': str(seconds), - **input_message.info + **input_message.info, } files = None if input_message.file: @@ -73,47 +71,39 @@ class Sora(SimpleService): if current_size != required_size: raise UnsupportedSize(current_size, required_size) buf = BytesIO() - img.save(buf, format="PNG") + img.save(buf, format='PNG') buf.seek(0) - files = { - "input_reference": ( - input_message.file.name, - buf, - mime_type - ) - } + files = {'input_reference': (input_message.file.name, buf, mime_type)} with httpx.Client( - base_url='https://api.openai.com/v1/', - proxy=f'{proxy.protocol}://{proxy.address}' if proxy else None, - headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, - timeout=600, + base_url='https://api.openai.com/v1/', + proxy=f'{proxy.protocol}://{proxy.address}' if proxy else None, + headers={'Authorization': f'Bearer {settings.OPENAI_API_KEY}'}, + timeout=600, ) as client: start_time = time.time() if files: - resp = client.post("videos", data=callback_data, files=files) + resp = client.post('videos', data=callback_data, files=files) else: - resp = client.post("videos", json=callback_data) + resp = client.post('videos', json=callback_data) if resp.status_code not in (200, 201): continue video_info = resp.json() - video_id = video_info.get("id") + video_id = video_info.get('id') while True: - status_resp = client.get(f"videos/{video_id}") - status = status_resp.json().get("status") + status_resp = client.get(f'videos/{video_id}') + status = status_resp.json().get('status') video_data = status_resp.json() - if status == "completed": + if status == 'completed': break - if status == "failed": - error_message = video_data.get("error", {}).get("message", "Unknown error") - if error_message == "Your request was blocked by our moderation system.": + if status == 'failed': + error_message = video_data.get('error', {}).get('message', 'Unknown error') + if error_message == 'Your request was blocked by our moderation system.': raise RequestBlocked - raise Exception("Video generation failed") - time.sleep(1/3) - video = client.get(f"videos/{video_id}/content").content + raise Exception('Video generation failed') + time.sleep(1 / 3) + video = client.get(f'videos/{video_id}/content').content process_time = timedelta(seconds=(time.time() - start_time)) - self.handle_invoice( - input_message.content_object.model, seconds, version - ) + self.handle_invoice(input_message.content_object.model, seconds, version) msgs = self.save_results(input_message.content, process_time, video, save) return msgs raise ModelTimeoutError @@ -35,4 +35,4 @@ class Stablediffusion(Seedream): def make(self, input_message: Message, save: bool = True) -> list[Message]: input_message.info = self._remap_info(input_message.info) - return super().make(input_message, save) \ No newline at end of file + return super().make(input_message, save) @@ -18,32 +18,29 @@ from payments.exceptions.insufficient_balance import InsufficientBalance from payments.selectors.payment_plan_selector import PaymentPlanSelector - class Video_Test_Model(SimpleService): - RATES = { 'per-unit': Decimal('15'), 'per-second': Decimal('0.5'), } - - - PLACEHOLDER_URL='https://imgur.com/QPLhtj1.mp4' - def calculate_price(self, strategy: Literal['per-unit','per-second'], duration: int = 1, num_videos: int = 1) -> Decimal: + PLACEHOLDER_URL = 'https://imgur.com/QPLhtj1.mp4' + + def calculate_price( + self, strategy: Literal['per-unit', 'per-second'], duration: int = 1, num_videos: int = 1 + ) -> Decimal: rate = self.RATES[strategy] return (rate * duration * num_videos).quantize(Decimal('0.1'), rounding='ROUND_UP') - @classmethod def predict_price(cls, content: str, file_exists: bool, info: dict[str, Any]) -> Decimal | None: num_videos = info.get('num_videos', 1) cps = info.get('cps', 'per-unit') - + if cps == 'per-second': return None - - return cls.RATES['per-unit'] * num_videos + return cls.RATES['per-unit'] * num_videos def save_results( self, @@ -66,75 +63,71 @@ class Video_Test_Model(SimpleService): return Message.objects.bulk_create(messages) return messages - def make(self, input_message: Message, save: bool = True) -> list[Message]: cps = input_message.info.get('cps', 'per-unit') cvu = input_message.info.get('cvu') or self.PLACEHOLDER_URL num_videos = input_message.info.get('num_videos', 1) - + start_time = time.time() video_bytes = self._fetch_video(cvu) - + if cps == 'per-second': duration = self._get_duration(video_bytes) else: duration = 1 - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < (cost := self.calculate_price(cps, duration, num_videos)): + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < ( + cost := self.calculate_price(cps, duration, num_videos) + ): raise InsufficientBalance(balance, cost) - - + videos = [video_bytes] * num_videos - + process_time = timedelta(seconds=(time.time() - start_time)) self.handle_invoice(input_message.content_object.model, cps, duration, num_videos) - + msgs = self.save_results(input_message.content, process_time, videos, save) return msgs - def _fetch_video(self, url: str): headers = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/137.0 Safari/537.36" + 'User-Agent': ( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/137.0 Safari/537.36' ) } try: - response = requests.get( - url, - headers=headers, - timeout=600 - ) + response = requests.get(url, headers=headers, timeout=600) response.raise_for_status() except requests.RequestException as exc: raise InvalidParameterError(f'Invalid video URL: {exc}') - + kind = filetype.guess(response.content[:120]) - + if not kind: raise CorruptedFileError - + if not kind.mime.startswith('video/'): raise InvalidParameterError('Video format not supported') - + return response.content - def _get_duration(self, video_bytes: bytes) -> int: result = subprocess.run( [ - "ffprobe", - "-v", "quiet", - "-print_format", "json", - "-show_format", - "-", + 'ffprobe', + '-v', + 'quiet', + '-print_format', + 'json', + '-show_format', + '-', ], input=video_bytes, capture_output=True, ) data = json.loads(result.stdout) - return math.ceil(float(data["format"]["duration"])) \ No newline at end of file + return math.ceil(float(data['format']['duration'])) @@ -42,7 +42,9 @@ class Wan_Lite(SimpleService): def make(self, input_message: Message, save: bool = True) -> list[Message]: resolution = input_message.info.pop('resolution', '720p') - if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[resolution]: + if (balance := PaymentPlanSelector(self.store.user).get_current_balance()) < self.TOKENS_COST[ + resolution + ]: raise InsufficientBalance(balance, self.TOKENS_COST[resolution]) callback_data = dict( { @@ -59,103 +59,103 @@ In sit amet nunc sed urna aliquet vehicula id vel justo. Duis vel massa eleifend """ ANCHORS = { - "authors": ( - "автор|авторы|составители|подготовители|команда|коллектив|исследователь|" - "исследователи|авторский коллектив|writer|researcher|investigator|" - "contributors|исполнители|ответственные лица|authorship|авторство|" - "group|team|authorship team", - 2 - ), - "topic": ( - "тема исследования|предмет исследования|тема работы|цель исследования|" - "предмет|направление|scope|research topic|subject of study|research focus|" - "object of study|scientific problem|область исследования|problem statement", - 2 - ), - "summary": ( - "краткое содержание|основные выводы|итоги исследования|summary|conclusions|" - "executive summary|highlights|abstract|overview|synopsis|выводы|" - "резюме|summary statement", - 6 - ), - "volume": ( - "объем рынка|market size|размер рынка|объем продаж|общие показатели|" - "объем инвестиций|total volume|market volume|рыночная капитализация|" - "оборот|объем финансирования|масштаб рынка|market capacity", - 4 - ), - "forecast": ( - "прогноз|forecast|прогнозные показатели|ожидания|перспективы|outlook|" - "прогноз развития|predicted values|прогноз роста|прогноз падения|" - "future outlook|прогноз на следующий год|прогноз на 3-5 лет", - 4 - ), - "growth_drivers": ( - "драйверы роста|факторы роста|причины роста|growth drivers|" - "growth factors|catalysts|key drivers|стимулирующие факторы|" - "факторы развития|движущие силы|причины повышения|рост рынка|growth enablers", - 4 - ), - "barriers": ( - "барьеры|препятствия|ограничения|риски|сложности|ограничения рынка|" - "barriers|obstacles|challenges|risks|факторы замедления|факторы риска|" - "рисковые факторы|проблемы|тормозящие развитие|негативные факторы", - 4 - ), - "regulations": ( - "регуляторные изменения|законодательство|нормативные акты|регулирование|" - "compliance|laws|regulations|legal changes|закон|правила|постановления|" - "стандарты|регулирующие органы|политические инициативы|правовые нормы", - 4 - ), - "segmentation": ( - "сегментация|разделение рынка|сегменты|группы клиентов|customer segments|" - "market segmentation|категории|подразделения|типы клиентов|демографические" - " группы|целевые аудитории|сегментация по регионам|product segmentation", - 4 - ), - "players": ( - "игроки рынка|компании|корпорации|основные участники|конкуренты|market " - "players|key companies|competitors|поставщики|лидеры рынка|крупные компании|" - "бизнес-игроки|участники рынка|основные бренды", - 4 - ), - "quant_metrics": ( - "количественные метрики|числовые показатели|quantitative metrics|цифры|" - "data points|измерения|показатели|объемы|количество сделок|темпы роста|" - "проценты|значения|финансовые показатели|статистика", - 2 - ), - "qual_metrics": ( - "качественные метрики|качественные показатели|qualitative metrics|оценки" - "|факторы оценки|quality indicators|мнение экспертов|экспертные оценки|" - "восприятие|качественные данные|отзывы|качественный анализ", - 2 - ), - "cases": ( - "кейсы|примеры|практические примеры|case studies|examples|use cases|" - "проекты|сценарии|успешные истории|best practices|опыт применения", - 1 - ), - "charts": ( - "графики|диаграммы|charts|diagrams|visualizations|plots|иллюстрации|" - "схемы|инфографика|data visualization|charts and graphs", - 1 - ), - "tables": ( - "таблицы|data tables|таблицы данных|spreadsheets|matrices|таблицы с данными|" - "табличные данные|списки|структуры данных|табличное представление", - 1 - ), - "methodology": ( - "методология|методы исследования|approach|methodology|methods|techniques|" - "исследовательские методы|методики|способы анализа|процедура|процесс исследования", - 1 - ), - "interviews": ( - "интервью|мнения экспертов|комментарии|expert interviews|expert opinions|" - "statements|reviews|опросы|интервью с экспертами|экспертные отзывы|" - "интервьюирование|отзывы участников", - 1 + 'authors': ( + 'автор|авторы|составители|подготовители|команда|коллектив|исследователь|' + 'исследователи|авторский коллектив|writer|researcher|investigator|' + 'contributors|исполнители|ответственные лица|authorship|авторство|' + 'group|team|authorship team', + 2, + ), + 'topic': ( + 'тема исследования|предмет исследования|тема работы|цель исследования|' + 'предмет|направление|scope|research topic|subject of study|research focus|' + 'object of study|scientific problem|область исследования|problem statement', + 2, + ), + 'summary': ( + 'краткое содержание|основные выводы|итоги исследования|summary|conclusions|' + 'executive summary|highlights|abstract|overview|synopsis|выводы|' + 'резюме|summary statement', + 6, + ), + 'volume': ( + 'объем рынка|market size|размер рынка|объем продаж|общие показатели|' + 'объем инвестиций|total volume|market volume|рыночная капитализация|' + 'оборот|объем финансирования|масштаб рынка|market capacity', + 4, + ), + 'forecast': ( + 'прогноз|forecast|прогнозные показатели|ожидания|перспективы|outlook|' + 'прогноз развития|predicted values|прогноз роста|прогноз падения|' + 'future outlook|прогноз на следующий год|прогноз на 3-5 лет', + 4, + ), + 'growth_drivers': ( + 'драйверы роста|факторы роста|причины роста|growth drivers|' + 'growth factors|catalysts|key drivers|стимулирующие факторы|' + 'факторы развития|движущие силы|причины повышения|рост рынка|growth enablers', + 4, + ), + 'barriers': ( + 'барьеры|препятствия|ограничения|риски|сложности|ограничения рынка|' + 'barriers|obstacles|challenges|risks|факторы замедления|факторы риска|' + 'рисковые факторы|проблемы|тормозящие развитие|негативные факторы', + 4, + ), + 'regulations': ( + 'регуляторные изменения|законодательство|нормативные акты|регулирование|' + 'compliance|laws|regulations|legal changes|закон|правила|постановления|' + 'стандарты|регулирующие органы|политические инициативы|правовые нормы', + 4, + ), + 'segmentation': ( + 'сегментация|разделение рынка|сегменты|группы клиентов|customer segments|' + 'market segmentation|категории|подразделения|типы клиентов|демографические' + ' группы|целевые аудитории|сегментация по регионам|product segmentation', + 4, + ), + 'players': ( + 'игроки рынка|компании|корпорации|основные участники|конкуренты|market ' + 'players|key companies|competitors|поставщики|лидеры рынка|крупные компании|' + 'бизнес-игроки|участники рынка|основные бренды', + 4, + ), + 'quant_metrics': ( + 'количественные метрики|числовые показатели|quantitative metrics|цифры|' + 'data points|измерения|показатели|объемы|количество сделок|темпы роста|' + 'проценты|значения|финансовые показатели|статистика', + 2, + ), + 'qual_metrics': ( + 'качественные метрики|качественные показатели|qualitative metrics|оценки' + '|факторы оценки|quality indicators|мнение экспертов|экспертные оценки|' + 'восприятие|качественные данные|отзывы|качественный анализ', + 2, + ), + 'cases': ( + 'кейсы|примеры|практические примеры|case studies|examples|use cases|' + 'проекты|сценарии|успешные истории|best practices|опыт применения', + 1, + ), + 'charts': ( + 'графики|диаграммы|charts|diagrams|visualizations|plots|иллюстрации|' + 'схемы|инфографика|data visualization|charts and graphs', + 1, + ), + 'tables': ( + 'таблицы|data tables|таблицы данных|spreadsheets|matrices|таблицы с данными|' + 'табличные данные|списки|структуры данных|табличное представление', + 1, + ), + 'methodology': ( + 'методология|методы исследования|approach|methodology|methods|techniques|' + 'исследовательские методы|методики|способы анализа|процедура|процесс исследования', + 1, + ), + 'interviews': ( + 'интервью|мнения экспертов|комментарии|expert interviews|expert opinions|' + 'statements|reviews|опросы|интервью с экспертами|экспертные отзывы|' + 'интервьюирование|отзывы участников', + 1, ), } @@ -144,7 +144,9 @@ class NeuronModel(BaseModel, OrderedModel): @property def service(self) -> 'SimpleService': # noqa: F821 - return getattr(importlib.import_module(f'ml_model.services.{self.slug}'), self.slug.replace('-', '').title()) + return getattr( + importlib.import_module(f'ml_model.services.{self.slug}'), self.slug.replace('-', '').title() + ) @property def streaming(self) -> bool: @@ -245,7 +245,7 @@ def openrouter_run(version: str, messages: list, callback_data: dict, model_name logger.error(f'Model {model_name} disabled') raise DeploymentDisabled else: - if re.match(r'^qwen/qwen3\.7-.*$', data['model']): + if re.match(r'^qwen/qwen3\.[78]-.*$', data['model']): input_tokens = data['usage']['cost'] output_tokens = 0 else: @@ -62,9 +62,9 @@ class NeuronModelAPIView(APIView): selector = NeuronModelSelector(request.user) model = selector.get_model_by_slug(slug=slug, hidden=False) if not selector.get_model_accessible_status(model): - return Response({'detail': _('Model data cannot be retrieved')}, status=status.HTTP_403_FORBIDDEN) - return Response( - NeuronModelSerializer(model).data - ) + return Response( + {'detail': _('Model data cannot be retrieved')}, status=status.HTTP_403_FORBIDDEN + ) + return Response(NeuronModelSerializer(model).data) except (NeuronModelNotExist, Exception) as exc: return Response({'detail': f'{exc}'}, status=status.HTTP_400_BAD_REQUEST) @@ -1,5 +1,6 @@ from django.utils.translation import gettext as _ + class FullBalanceException(Exception): def __str__(self) -> str: - return _('Your balance is already full') \ No newline at end of file + return _('Your balance is already full') @@ -12,12 +12,13 @@ class PaymentAttempt(BaseModel): verbose_name=_('Payment Method'), related_name='payment_attempts', ) + # FIXME: переименовать в reason + # FIXME: blank=True, null=True пересмотреть, т.к Attempt - это FailedAttempt и причина скорее всего есть всегдад cancel_reason = models.CharField(max_length=50, blank=True, null=True, verbose_name=_('Cancel Reason')) + # FIXME: связывать попытки на основании метадаты при повторных попытках оплаты + # FIXME: вместо флага должен быть trace-id или подобный атрибут, который будет явно описывать, какие попытки связаны in_cycle = models.BooleanField(default=True, verbose_name=_('In Cycle')) - def __str__(self) -> str: - return f'Attempt of ({self.method})\nReason: {self.cancel_reason}' - class Meta: verbose_name = _('Payment Attempt') verbose_name_plural = _('Payment Attempts') @@ -15,7 +15,9 @@ class PaymentPlanFeature(OrderedModel): plan = models.ForeignKey( PaymentPlan, on_delete=models.CASCADE, related_name='features', verbose_name=_('Payment Plan') ) - model = models.ForeignKey(NeuronModel, on_delete=models.CASCADE, related_name='feature', verbose_name=_('Neuron Model')) + model = models.ForeignKey( + NeuronModel, on_delete=models.CASCADE, related_name='feature', verbose_name=_('Neuron Model') + ) quantity = models.PositiveIntegerField(default=1, verbose_name=_('Quantity')) measurement_unit = models.CharField( max_length=15, @@ -26,12 +26,14 @@ class PaymentMethod(BaseModel): active = models.BooleanField(default=False, verbose_name=_('Active')) primary = models.BooleanField(default=True, verbose_name=_('Primary')) + # FIXME: в property не должно быть запросов, для использования property объект(-ы) должен быть аннотирован через .annotate @property def attempts(self): if hasattr(self, 'total_attempts'): return self.total_attempts return self.payment_attempts.filter(in_cycle=True).count() + # FIXME: должно быть перенесено на уровень сервисов, мы не изменяем метод save модели def save(self, *args, **kwargs): with transaction.atomic(): if self.primary: @@ -114,6 +114,7 @@ class PlansAPITest(BaseAuthorizedAPITest): def test_unauthorized_by_permission(self) -> None: from authentication.models import CustomUserModel + host_user = CustomUserModel.objects.create_user(email='test_2@test.test', password='test_2') host = BusinessUserHost.objects.create(user=host_user) BusinessAccount.objects.create(user=self.user, parent_company=host) @@ -239,4 +240,4 @@ class PlansAPITest(BaseAuthorizedAPITest): def test_tokens_per_plan(self) -> None: plans = self.get().json() for plan in plans: - self.assertGreater(Decimal(str(plan['tokens_per_plan'])), 0) \ No newline at end of file + self.assertGreater(Decimal(str(plan['tokens_per_plan'])), 0) @@ -79,4 +79,4 @@ class BalanceAPITest(BaseAuthorizedAPITest): ) business_account.save() balance = self.get().json()['current_token_balance'] - self.assertEqual(Decimal(balance), business_account.group.token_limit) \ No newline at end of file + self.assertEqual(Decimal(balance), business_account.group.token_limit) @@ -314,4 +314,3 @@ class ReferralAccountAdmin(admin.ModelAdmin): @admin.display(description='Получено бонусов') def _accrued_bonuses(self, obj: ReferralAccount): return f'{obj.accrued_bonuses.aggregate(total=Coalesce(Sum("amount"), Decimal(0), output_field=models.DecimalField()))["total"]} токенов' - @@ -30,4 +30,4 @@ def clear_recurrent_on_individual_plan_assignment( if not instance.plan.individual: return PaymentPlanUserInfo.objects.filter(pk=instance.pk).update(next_payment_at=None) - PaymentMethodService(instance.user).deactivate_payment_methods(notify=None) \ No newline at end of file + PaymentMethodService(instance.user).deactivate_payment_methods(notify=None) @@ -22,7 +22,10 @@ class SendErrorReportEmailAPIView(APIView): data = serializer.validated_data report = Report( message=data['report_text'], - attachments=[File(file.name, file.file, file.content_type, file.size) for file in data.get('images', [])] + attachments=[ + File(file.name, file.file, file.content_type, file.size) + for file in data.get('images', []) + ], ) EmailService(request.user).send_error_email(report) return Response({'detail': 'error report sent'}, status=status.HTTP_201_CREATED) @@ -76,4 +76,3 @@ class PublicSSEStoreService(SSEStoreService): def _get_cache_key(self) -> str: return f'sse:tokens:{self.user_uuid}:{self.message_uuid}:public' - \ No newline at end of file @@ -72,4 +72,3 @@ class MessageSchema(ModelSchema): if isinstance(value, str): return value return value.url - @@ -1,19 +1,30 @@ from typing import List +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.prefetch import GenericPrefetch from django.core.exceptions import ValidationError +from django.db.models import Subquery from django.utils.translation import gettext as _ from ninja import File, Form, Router, UploadedFile from ninja.errors import HttpError +from ninja.pagination import paginate, CursorPagination from authentication.security import SyncAuthBearer from ml_model.models import NeuronModel from ml_model.schemas import NeuronModelLink -from tools.media.models import Preset, Voice + +from messages.models import Message +from tools.chats.schemas import MessageSchema + +from tools.media.models import Audio, Image, Preset, Video, Voice, VoiceClone from tools.media.schemas import PresetSchema, UpdateVoiceSchema, VoiceSchema -from tools.media.typing import PresetKindEnum +from tools.media.typing import PresetKindEnum, StoreEnum router = Router(auth=SyncAuthBearer(), tags=['media']) +STORES = {'audio': Audio, 'image': Image, 'video': Video, 'voice_clone': VoiceClone} +MEDIA_CONTENT_TYPES = {store: ContentType.objects.get_for_model(model) for store, model in STORES.items()} + @router.get( 'images/links/', @@ -30,6 +41,44 @@ def get_links(request): ) +@router.get( + 'generations/', + tags=['media/'], + response=List[MessageSchema], +) +@paginate(CursorPagination, ordering=('-created_at', 'uid'), page_size=10, max_page_size=50) +def list_gallery_generations(request, store: StoreEnum): + store_model = STORES[store] + store_ids = store_model.objects.filter(user=request.auth).values('uid') + return ( + Message.objects.filter( + content_type_id=MEDIA_CONTENT_TYPES[store].pk, + object_id__in=Subquery(store_ids), + from_model=True, + is_deleted=False, + ) + .prefetch_related( + GenericPrefetch( + 'content_object', + [store_model.objects.select_related('model').only('uid', 'model__slug', 'model__category_id')], + ) + ) + .only( + 'uid', + 'content', + 'file', + 'from_model', + 'created_at', + 'elapsed_time', + 'is_favourite', + 'is_sent', + 'info', + 'content_type', + 'object_id', + ) + ) + + @router.post('voices/', tags=['media/voices'], auth=SyncAuthBearer(), response={201: None, 400: str}) def upload_voice( request, @@ -19,4 +19,4 @@ class VoiceSchema(ModelSchema): class PresetSchema(ModelSchema): class Meta: model = Preset - fields = ('uid', 'title', 'file', 'metadata') \ No newline at end of file + fields = ('uid', 'title', 'file', 'metadata') @@ -3,4 +3,11 @@ from enum import Enum class PresetKindEnum(str, Enum): voice = 'voice' - instrumental = 'instrumental' \ No newline at end of file + instrumental = 'instrumental' + + +class StoreEnum(str, Enum): + audio = 'audio' + image = 'image' + video = 'video' + voice_clone = 'voice_clone' @@ -119,7 +119,9 @@ def openai_responses_stream(request, body: dict): if not (model_ref := body.get('model')): raise HttpError(400, _('You must provide a model parameter')) model = _resolve_model(model_ref) - return _to_openai(public_stream_message(request, model.slug, _parse_body(body)), model_ref, request=request) + return _to_openai( + public_stream_message(request, model.slug, _parse_body(body)), model_ref, request=request + ) @OpenAIErrorService.view @@ -6,14 +6,25 @@ from ninja.errors import HttpError class OpenAIErrorService: TYPES = { - 400: 'invalid_request_error', 401: 'authentication_error', 403: 'permission_error', - 404: 'invalid_request_error', 409: 'invalid_request_error', 501: 'api_error', + 400: 'invalid_request_error', + 401: 'authentication_error', + 403: 'permission_error', + 404: 'invalid_request_error', + 409: 'invalid_request_error', + 501: 'api_error', } @classmethod def response(cls, status: int, message: str) -> JsonResponse: return JsonResponse( - {'error': {'message': str(message), 'type': cls.TYPES.get(status, 'api_error'), 'param': None, 'code': None}}, + { + 'error': { + 'message': str(message), + 'type': cls.TYPES.get(status, 'api_error'), + 'param': None, + 'code': None, + } + }, status=status, ) @@ -22,8 +22,7 @@ class BaseElevenlabsAPIView: def _authorization_headers(self, request): return { - 'Authorization': request.headers.get('Xi-Api-Key') - or request.headers.get('Authorization', ''), + 'Authorization': request.headers.get('Xi-Api-Key') or request.headers.get('Authorization', ''), } def _proxy_request(self, request, data=None): @@ -173,7 +173,9 @@ class OpenAIVoiceAPIView(PublicVoiceUploadAPIView, PublicVoiceListAPIView): name = request.data.get('name') sample = request.FILES.get('audio_sample') if not sample: - return _openai_error(_('Missing audio_sample.'), param='audio_sample', code='missing_required_parameter') + return _openai_error( + _('Missing audio_sample.'), param='audio_sample', code='missing_required_parameter' + ) proxy = type( 'RequestProxy', @@ -278,7 +280,9 @@ class OpenAIAudioSpeechAPIView(VoiceView): if st == status.HTTP_403_FORBIDDEN: return _openai_error(str(detail), err_type='permission_error', http_status=st) if st >= 500: - return _openai_error(str(detail), err_type='api_error', code='internal_error', http_status=st) + return _openai_error( + str(detail), err_type='api_error', code='internal_error', http_status=st + ) return _openai_error(str(detail), http_status=st) r = httpx.get(result.data[0]['file'], timeout=120.0) if r.status_code >= 400: @@ -0,0 +1,30 @@ +# Generated by Django 5.0 on 2026-07-30 14:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('msgs', '0010_message_msgs_messag_object__09670f_idx'), + ] + + operations = [ + migrations.CreateModel( + name='Share', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=8, unique=True, verbose_name='Code')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('expires_at', models.DateTimeField(verbose_name='Expires At')), + ('messages', models.ManyToManyField(to='msgs.message', verbose_name='Messages')), + ], + options={ + 'verbose_name': 'Share', + 'verbose_name_plural': 'Shares', + 'ordering': ('expires_at',), + }, + ), + ] @@ -0,0 +1,40 @@ +from ninja import Router +from ninja.errors import HttpError + +from authentication.security import SyncAuthBearer +from tools.share.exceptions import ( + IncompleteSetError, + MultiStoreShareError, + OnlyModelMessagesShareError, + OwnershipError, + ShareDoesNotExist, + StoreShareError, +) +from tools.share.schemas import ShareResultSchema, ShareSchema, ShareMessagesSchema +from tools.share.services.share_service import ShareService + +router = Router(auth=SyncAuthBearer(), tags=['share']) + + +@router.post('/', tags=['share/'], response=ShareSchema) +def add_share_messages(request, payload: ShareMessagesSchema): + try: + share = ShareService(request.auth, list(set(payload.messages_uids))).share() + return ShareSchema(code=share.code, expires_at=share.expires_at) + except OwnershipError as exc: + raise HttpError(403, str(exc)) + except (MultiStoreShareError, StoreShareError, IncompleteSetError, OnlyModelMessagesShareError) as exc: + raise HttpError(400, str(exc)) + except Exception as exc: + raise HttpError(400, str(exc)) + + +@router.get('{code}/', auth=None, tags=['share/'], response=ShareResultSchema) +def list_share_messages(request, code: str): + try: + store_type, messages = ShareService.get_share_messages(code) + return ShareResultSchema(store_type=store_type, messages=messages) + except ShareDoesNotExist as exc: + raise HttpError(404, str(exc)) + except Exception as exc: + raise HttpError(400, str(exc)) @@ -0,0 +1,101 @@ +import base64 +import hashlib +from datetime import timedelta + +from uuid import UUID + +from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.db.models import Prefetch +from django.utils import timezone + +from authentication.models import CustomUserModel +from core.service import BaseService +from messages.models import Message +from tools.chats.models import Chat +from tools.media.models import Gallery +from tools.public_api.models import APIStore +from tools.share.exceptions import ( + IncompleteSetError, + MultiStoreShareError, + OnlyModelMessagesShareError, + OwnershipError, + ShareDoesNotExist, + StoreShareError, +) +from tools.share.models import Share + + +class ShareService(BaseService): + SHARE_LIFETIME = settings.SHARE_LIFETIME + + def __init__(self, user: CustomUserModel, message_uids: list[UUID]) -> None: + super().__init__(user) + self.message_uids = message_uids + + def _generate_code(self): + return base64.urlsafe_b64encode( + hashlib.sha256((','.join(sorted(map(str, self.message_uids)))).encode()).digest() + ).decode()[:8] + + def _validate_share_messages(self) -> None: + """ + Проверяет, что все сообщения существуют, принадлежат одному стору текущего пользователя + и могут быть расшарены. Исключает сообщения из неподдерживаемых типов сторов. + """ + qs = Message.objects.filter(uid__in=self.message_uids, is_deleted=False) + raw_pairs = list(qs.values_list('content_type', 'object_id')) + if not raw_pairs or len(raw_pairs) != len(self.message_uids): + raise IncompleteSetError + pairs = set(raw_pairs) + if len(pairs) > 1: + raise MultiStoreShareError + for content_type, object_id in pairs: + model = ContentType.objects.get_for_id(content_type).model_class() + if model is APIStore: + raise StoreShareError + model_filter = {'pk': object_id, 'user': self.user} + if model is Chat: + model_filter['is_deleted'] = False + if not model.objects.filter(**model_filter).exists(): + raise OwnershipError + if issubclass(model, Gallery) and qs.filter(from_model=False).exists(): + raise OnlyModelMessagesShareError + + def _create_share(self, code: str) -> Share: + with transaction.atomic(): + share, created = Share.objects.update_or_create( + code=code, defaults=dict(expires_at=timezone.now() + timedelta(seconds=self.SHARE_LIFETIME)) + ) + if created: + share.messages.add(*self.message_uids) + return share + + def share(self) -> Share: + self._validate_share_messages() + code = self._generate_code() + share = self._create_share(code) + return share + + @classmethod + def get_share_messages(cls, code: str) -> tuple[str, list[Message]]: + share = ( + Share.objects.filter(code=code, expires_at__gt=timezone.now()) + .prefetch_related( + Prefetch( + 'messages', + queryset=Message.objects.filter(is_deleted=False).select_related('content_type'), + ) + ) + .first() + ) + if not share: + raise ShareDoesNotExist + + messages = list(share.messages.all()) + if not messages: + raise ShareDoesNotExist + + store_type = messages[0].content_type.model + return store_type, messages @@ -0,0 +1,22 @@ +from django.contrib import admin + +from tools.share.models import Share + + +class ShareMessageInline(admin.TabularInline): + model = Share.messages.through + extra = 0 + can_delete = False + readonly_fields = ('message',) + fields = ('message',) + + def has_add_permission(self, request, obj=None) -> bool: + return False + + +@admin.register(Share) +class ShareAdmin(admin.ModelAdmin): + list_display = ('code', 'created_at', 'expires_at') + search_fields = ('code',) + readonly_fields = ('code', 'created_at', 'expires_at') + inlines = [ShareMessageInline] @@ -0,0 +1,31 @@ +from django.utils.translation import gettext as _ + + +class OwnershipError(Exception): + def __str__(self) -> str: + return _('Some messages are unavailable to you') + + +class MultiStoreShareError(Exception): + def __str__(self) -> str: + return _('Link access can only include messages from one store') + + +class StoreShareError(Exception): + def __str__(self) -> str: + return _('Link access is not available for this store type') + + +class OnlyModelMessagesShareError(Exception): + def __str__(self) -> str: + return _('Link access for this store can only include model messages') + + +class IncompleteSetError(Exception): + def __str__(self) -> str: + return _('Not all messages were found. Some may have been deleted') + + +class ShareDoesNotExist(Exception): + def __str__(self) -> str: + return _('The link was not found or is no longer available') @@ -0,0 +1,19 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from messages.models import Message + + +class Share(models.Model): + code = models.CharField(max_length=8, unique=True, verbose_name=_('Code')) + messages = models.ManyToManyField(Message, blank=False, verbose_name=_('Messages')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + expires_at = models.DateTimeField(verbose_name=_('Expires At')) + + def __str__(self) -> str: + return self.code + + class Meta: + verbose_name = _('Share') + verbose_name_plural = _('Shares') + ordering = ('expires_at',) @@ -0,0 +1,20 @@ +from datetime import datetime +from uuid import UUID + +from ninja import Schema + +from tools.chats.schemas import MessageSchema + + +class ShareMessagesSchema(Schema): + messages_uids: list[UUID] + + +class ShareSchema(Schema): + code: str + expires_at: datetime + + +class ShareResultSchema(Schema): + store_type: str + messages: list[MessageSchema] @@ -0,0 +1,9 @@ +from celery import shared_task +from django.utils import timezone + +from tools.share.models import Share + + +@shared_task +def delete_expired_shares() -> None: + Share.objects.filter(expires_at__lte=timezone.now()).delete() @@ -24,3 +24,9 @@ class MediaConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'tools.media' verbose_name = _('Media') + + +class ShareConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'tools.share' + verbose_name = _('Share') \ No newline at end of file @@ -119,4 +119,7 @@ PYTHONWARNINGS=ignore::UserWarning:polymorphic # temporarily # SSE STREAMING FF__STREAMING_ENABLED=True +# MESSAGE SHARING +SHARE_LIFETIME=1800 + DATA_UPLOAD_MAX_MEMORY_SIZE=5 # MB \ No newline at end of file @@ -15,7 +15,7 @@ dependencies = [ "django-filter==23.2", "django-import-export==4.0.9", "django-minio-backend", - "django-ninja==1.3.0", + "django-ninja==1.6.2", "django-oauth-toolkit==2.3.0", "django-ordered-model==3.7.4", "django-polymorphic==3.1.0", @@ -44,6 +44,7 @@ dependencies = [ "pillow==12.2.0", "psycopg2-binary==2.9.10", "pydub==0.25.1", + "pyexcelerate>=0.13.0", "pymupdf==1.26.1", "pypandoc==1.15", "pypdf2==3.0.1", @@ -303,6 +303,7 @@ dependencies = [ { name = "pillow" }, { name = "psycopg2-binary" }, { name = "pydub" }, + { name = "pyexcelerate" }, { name = "pymupdf" }, { name = "pypandoc" }, { name = "pypdf2" }, @@ -340,7 +341,7 @@ requires-dist = [ { name = "django-filter", specifier = "==23.2" }, { name = "django-import-export", specifier = "==4.0.9" }, { name = "django-minio-backend", git = "https://github.com/theriverman/django-minio-backend?tag=3.7.0" }, - { name = "django-ninja", specifier = "==1.3.0" }, + { name = "django-ninja", specifier = "==1.6.2" }, { name = "django-oauth-toolkit", specifier = "==2.3.0" }, { name = "django-ordered-model", specifier = "==3.7.4" }, { name = "django-polymorphic", specifier = "==3.1.0" }, @@ -369,6 +370,7 @@ requires-dist = [ { name = "pillow", specifier = "==12.2.0" }, { name = "psycopg2-binary", specifier = "==2.9.10" }, { name = "pydub", specifier = "==0.25.1" }, + { name = "pyexcelerate", specifier = ">=0.13.0" }, { name = "pymupdf", specifier = "==1.26.1" }, { name = "pypandoc", specifier = "==1.15" }, { name = "pypdf2", specifier = "==3.0.1" }, @@ -963,15 +965,15 @@ dependencies = [ [[package]] name = "django-ninja" -version = "1.3.0" +version = "1.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/77/89ee4ebaa5151b7d85cebaf8d6ec0b9e5074326c3ad8259c763763306d51/django_ninja-1.3.0.tar.gz", hash = "sha256:5b320e2dc0f41a6032bfa7e1ebc33559ae1e911a426f0c6be6674a50b20819be", size = 3702324, upload-time = "2024-08-15T09:15:04.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/7c/3307e17b872f545c88314b2737a22f965785dfb5a120d739b0131d0492c3/django_ninja-1.6.2.tar.gz", hash = "sha256:d56ae5aa4791068ef4ac9a66cfdf2fc11f507413ded35abb79c51d0d52ad6412", size = 3685599, upload-time = "2026-03-18T20:06:47.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/72/fd2589323b40893d3224e174eeec0c4ce5a42c7d2d384d11ba269ad4d050/django_ninja-1.3.0-py3-none-any.whl", hash = "sha256:f58096b6c767d1403dfd6c49743f82d780d7b9688d9302ecab316ac1fa6131bb", size = 2423381, upload-time = "2024-08-15T09:15:02.396Z" }, + { url = "https://files.pythonhosted.org/packages/21/0c/25f72060a39632fbd2d90e9c8b6052a09cd45b0598fc06c0758d313f0052/django_ninja-1.6.2-py3-none-any.whl", hash = "sha256:20095f5900bada22ea00cf1a58af50bdb285b2354c61a9d9b47d0dc89ac462d6", size = 2374994, upload-time = "2026-03-18T20:06:45.676Z" }, ] [[package]] @@ -1682,6 +1684,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "jiter" version = "0.15.0" @@ -2082,6 +2096,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/2c/0f1e93c636720e8a3eb59af2bfda99d98b55891e1c53bc30c2e0e865f01b/lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c", size = 3817223, upload-time = "2026-05-19T19:22:56.823Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "marshmallow" version = "3.26.2" @@ -2954,6 +3031,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" }, ] +[[package]] +name = "pyexcelerate" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/49/84f7812fb47ebe6b16cc4b36759576d99f8779affe37753cdd59b0c9bcf9/pyexcelerate-0.13.0.tar.gz", hash = "sha256:a3d20c9aa3cf6685603efa16259d44a18165f3544597cb8cb2b486c58ca14b37", size = 29618, upload-time = "2025-05-23T16:51:32.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/f8/ee05eeb3b865f3bb46816493e89289acbe48f2b3cb9f81fef78862c97ea7/pyexcelerate-0.13.0-py3-none-any.whl", hash = "sha256:c78be1d45a35e1b3db75d1d229b7fd36a76829a8dda05b66336cd1b46565634b", size = 29006, upload-time = "2025-05-23T16:51:30.995Z" }, +] + [[package]] name = "pyjwt" version = "2.13.0"