NODEDC_TASKMANAGER/plane-src/apps/api/plane/app/views/voice_tasker.py

232 lines
8.5 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
from openai import OpenAI
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import WorkspaceAISettingsSerializer
from plane.db.models import Workspace, WorkspaceAICredential, WorkspaceAISettings, WorkspaceMember
from plane.license.utils.encryption import decrypt_data
from plane.utils.exception_logger import log_exception
from .base import BaseAPIView
VOICE_TASK_ACCEPTED_AUDIO_TYPES = ["audio/webm", "audio/mp4", "audio/mpeg", "audio/wav"]
def get_voice_task_preflight(workspace, user):
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first()
response = {
"available": False,
"reason": "not_configured",
"max_audio_duration_seconds": 120,
"accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES,
"access_mode": "all_workspace_members",
}
if not ai_settings:
return response
response["max_audio_duration_seconds"] = ai_settings.max_audio_duration_seconds
response["access_mode"] = ai_settings.access_mode
if not ai_settings.voice_tasker_enabled:
response["reason"] = "disabled"
return response
credential = WorkspaceAICredential.objects.filter(
workspace=workspace,
provider=ai_settings.provider,
is_active=True,
).first()
if not credential or not credential.encrypted_api_key:
response["reason"] = "missing_api_key"
return response
if ai_settings.access_mode == WorkspaceAISettings.AccessMode.ADMINS_ONLY:
if not workspace_member or workspace_member.role != ROLE.ADMIN.value:
response["reason"] = "role_denied"
return response
response["available"] = True
response["reason"] = None
return response
class WorkspaceAISettingsEndpoint(BaseAPIView):
def get_settings(self, slug):
workspace = Workspace.objects.get(slug=slug)
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
return workspace, ai_settings
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
workspace, ai_settings = self.get_settings(slug)
serializer = WorkspaceAISettingsSerializer(ai_settings, context={"workspace": workspace})
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug):
workspace, ai_settings = self.get_settings(slug)
serializer = WorkspaceAISettingsSerializer(
ai_settings,
data=request.data,
partial=True,
context={"workspace": workspace},
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class WorkspaceAISettingsTestConnectionEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
credential = WorkspaceAICredential.objects.filter(
workspace=workspace,
provider=ai_settings.provider,
is_active=True,
).first()
if not credential or not credential.encrypted_api_key:
return Response(
{
"ok": False,
"code": "missing_api_key",
"error": "OpenAI API key is not configured for this workspace.",
},
status=status.HTTP_400_BAD_REQUEST,
)
api_key = decrypt_data(credential.encrypted_api_key)
if not api_key:
return Response(
{
"ok": False,
"code": "invalid_encrypted_key",
"error": "OpenAI API key could not be decrypted.",
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
client = OpenAI(api_key=api_key)
client.models.retrieve(ai_settings.structuring_model)
return Response(
{
"ok": True,
"provider": ai_settings.provider,
"model": ai_settings.structuring_model,
},
status=status.HTTP_200_OK,
)
except Exception as exc:
log_exception(exc)
error_type = exc.__class__.__name__
status_code = status.HTTP_400_BAD_REQUEST
error_code = "openai_connection_failed"
if error_type == "AuthenticationError":
error_code = "invalid_api_key"
elif error_type == "RateLimitError":
error_code = "rate_limited"
status_code = status.HTTP_429_TOO_MANY_REQUESTS
return Response(
{
"ok": False,
"code": error_code,
"error": "OpenAI connection check failed.",
},
status=status_code,
)
class VoiceTaskPreflightEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
return Response(get_voice_task_preflight(workspace, request.user), status=status.HTTP_200_OK)
class VoiceTaskParseEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
preflight = get_voice_task_preflight(workspace, request.user)
if not preflight["available"]:
response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST
return Response(
{
"ok": False,
"code": preflight["reason"],
"error": "Voice Tasker is not available for this workspace.",
},
status=response_status,
)
audio = request.FILES.get("audio")
if not audio:
return Response(
{"ok": False, "code": "missing_audio", "error": "Audio file is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if audio.content_type not in VOICE_TASK_ACCEPTED_AUDIO_TYPES:
return Response(
{"ok": False, "code": "unsupported_audio_type", "error": "Unsupported audio file type."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
duration_seconds = float(request.data.get("duration_seconds", 0))
except (TypeError, ValueError):
duration_seconds = 0
if duration_seconds <= 0:
return Response(
{"ok": False, "code": "invalid_duration", "error": "Audio duration is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if duration_seconds > preflight["max_audio_duration_seconds"]:
return Response(
{"ok": False, "code": "audio_too_long", "error": "Audio duration exceeds workspace limit."},
status=status.HTTP_400_BAD_REQUEST,
)
client_context_raw = request.data.get("client_context") or "{}"
try:
client_context = json.loads(client_context_raw)
except (TypeError, json.JSONDecodeError):
client_context = {}
return Response(
{
"ok": True,
"status": "uploaded",
"pipeline_status": "pending_openai_pipeline",
"audio": {
"content_type": audio.content_type,
"duration_seconds": duration_seconds,
"size": audio.size,
},
"client_context": client_context,
},
status=status.HTTP_202_ACCEPTED,
)