|
|
|
@@ -61,10 +61,14 @@ VOICE_TASK_INTENTS = {"create_task", "update_task", "delete_task", "unknown"}
|
|
|
|
|
VOICE_TASK_PRIORITIES = {"none", "low", "medium", "high", "urgent"}
|
|
|
|
|
VOICE_TASK_MEMORY_LIMIT = 5
|
|
|
|
|
VOICE_TASK_CONTEXT_LIMIT = 100
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS = 60 * 60
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_HOURLY_WINDOW_SECONDS = 60 * 60
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS = 24 * 60 * 60
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_ERROR_CODES = {
|
|
|
|
|
"voice_task_user_hourly_limit_exceeded",
|
|
|
|
|
"voice_task_workspace_hourly_limit_exceeded",
|
|
|
|
|
"voice_task_user_daily_limit_exceeded",
|
|
|
|
|
"voice_task_workspace_daily_limit_exceeded",
|
|
|
|
|
"voice_task_project_daily_limit_exceeded",
|
|
|
|
|
}
|
|
|
|
|
VOICE_TASK_PROJECT_MATCH_THRESHOLD = 0.8
|
|
|
|
|
VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD = 0.8
|
|
|
|
@@ -314,84 +318,163 @@ def get_voice_task_preflight(workspace, user, project_id=None):
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_voice_task_rate_limit_state(workspace, user, ai_settings, now=None):
|
|
|
|
|
def get_voice_task_quota_project(workspace, project_id=None, ai_settings=None):
|
|
|
|
|
if project_id:
|
|
|
|
|
project = Project.objects.filter(
|
|
|
|
|
id=project_id,
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
archived_at__isnull=True,
|
|
|
|
|
).first()
|
|
|
|
|
if project:
|
|
|
|
|
return project
|
|
|
|
|
|
|
|
|
|
if ai_settings and ai_settings.default_project_id:
|
|
|
|
|
return Project.objects.filter(
|
|
|
|
|
id=ai_settings.default_project_id,
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
archived_at__isnull=True,
|
|
|
|
|
).first()
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_voice_task_limit_sessions(workspace, window_seconds, now=None):
|
|
|
|
|
now = now or timezone.now()
|
|
|
|
|
window_start = now - timedelta(seconds=VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
|
sessions = VoiceTaskSession.objects.filter(
|
|
|
|
|
window_start = now - timedelta(seconds=window_seconds)
|
|
|
|
|
return VoiceTaskSession.objects.filter(
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
created_at__gte=window_start,
|
|
|
|
|
created_at__lte=now,
|
|
|
|
|
).exclude(error_code__in=VOICE_TASK_RATE_LIMIT_ERROR_CODES)
|
|
|
|
|
user_sessions = sessions.filter(user=user)
|
|
|
|
|
|
|
|
|
|
user_used = user_sessions.count()
|
|
|
|
|
workspace_used = sessions.count()
|
|
|
|
|
user_limit = max(int(ai_settings.per_user_hourly_limit or 0), 0)
|
|
|
|
|
workspace_limit = max(int(ai_settings.workspace_hourly_limit or 0), 0)
|
|
|
|
|
|
|
|
|
|
def get_voice_task_rate_limit_state(workspace, user, project, ai_settings, now=None):
|
|
|
|
|
hourly_sessions = get_voice_task_limit_sessions(
|
|
|
|
|
workspace,
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_HOURLY_WINDOW_SECONDS,
|
|
|
|
|
now=now,
|
|
|
|
|
)
|
|
|
|
|
daily_sessions = get_voice_task_limit_sessions(
|
|
|
|
|
workspace,
|
|
|
|
|
VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS,
|
|
|
|
|
now=now,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
user_hourly_used = hourly_sessions.filter(user=user).count()
|
|
|
|
|
workspace_hourly_used = hourly_sessions.count()
|
|
|
|
|
user_daily_used = daily_sessions.filter(user=user).count()
|
|
|
|
|
workspace_daily_used = daily_sessions.count()
|
|
|
|
|
project_daily_used = daily_sessions.filter(project=project).count() if project else 0
|
|
|
|
|
|
|
|
|
|
user_hourly_limit = max(int(ai_settings.per_user_hourly_limit or 0), 0)
|
|
|
|
|
workspace_hourly_limit = max(int(ai_settings.workspace_hourly_limit or 0), 0)
|
|
|
|
|
user_daily_limit = max(int(ai_settings.per_user_daily_limit or 0), 0)
|
|
|
|
|
workspace_daily_limit = max(int(ai_settings.workspace_daily_limit or 0), 0)
|
|
|
|
|
project_daily_limit = max(int(ai_settings.project_daily_limit or 0), 0)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"window_start": window_start,
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS,
|
|
|
|
|
"user": {
|
|
|
|
|
"used": user_used,
|
|
|
|
|
"limit": user_limit,
|
|
|
|
|
"exceeded": bool(user_limit and user_used >= user_limit),
|
|
|
|
|
"user_hourly": {
|
|
|
|
|
"scope": "user",
|
|
|
|
|
"window": "hour",
|
|
|
|
|
"used": user_hourly_used,
|
|
|
|
|
"limit": user_hourly_limit,
|
|
|
|
|
"exceeded": bool(user_hourly_limit and user_hourly_used >= user_hourly_limit),
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_HOURLY_WINDOW_SECONDS,
|
|
|
|
|
"code": "voice_task_user_hourly_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker user hourly limit exceeded.",
|
|
|
|
|
},
|
|
|
|
|
"workspace": {
|
|
|
|
|
"used": workspace_used,
|
|
|
|
|
"limit": workspace_limit,
|
|
|
|
|
"exceeded": bool(workspace_limit and workspace_used >= workspace_limit),
|
|
|
|
|
"workspace_hourly": {
|
|
|
|
|
"scope": "workspace",
|
|
|
|
|
"window": "hour",
|
|
|
|
|
"used": workspace_hourly_used,
|
|
|
|
|
"limit": workspace_hourly_limit,
|
|
|
|
|
"exceeded": bool(workspace_hourly_limit and workspace_hourly_used >= workspace_hourly_limit),
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_HOURLY_WINDOW_SECONDS,
|
|
|
|
|
"code": "voice_task_workspace_hourly_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker workspace hourly limit exceeded.",
|
|
|
|
|
},
|
|
|
|
|
"user_daily": {
|
|
|
|
|
"scope": "user",
|
|
|
|
|
"window": "day",
|
|
|
|
|
"used": user_daily_used,
|
|
|
|
|
"limit": user_daily_limit,
|
|
|
|
|
"exceeded": bool(user_daily_limit and user_daily_used >= user_daily_limit),
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS,
|
|
|
|
|
"code": "voice_task_user_daily_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker user daily limit exceeded.",
|
|
|
|
|
},
|
|
|
|
|
"workspace_daily": {
|
|
|
|
|
"scope": "workspace",
|
|
|
|
|
"window": "day",
|
|
|
|
|
"used": workspace_daily_used,
|
|
|
|
|
"limit": workspace_daily_limit,
|
|
|
|
|
"exceeded": bool(workspace_daily_limit and workspace_daily_used >= workspace_daily_limit),
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS,
|
|
|
|
|
"code": "voice_task_workspace_daily_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker workspace daily limit exceeded.",
|
|
|
|
|
},
|
|
|
|
|
"project_daily": {
|
|
|
|
|
"scope": "project",
|
|
|
|
|
"window": "day",
|
|
|
|
|
"used": project_daily_used,
|
|
|
|
|
"limit": project_daily_limit,
|
|
|
|
|
"exceeded": bool(project and project_daily_limit and project_daily_used >= project_daily_limit),
|
|
|
|
|
"window_seconds": VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS,
|
|
|
|
|
"code": "voice_task_project_daily_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker project daily limit exceeded.",
|
|
|
|
|
"project_id": str(project.id) if project else None,
|
|
|
|
|
"project_identifier": project.identifier if project else None,
|
|
|
|
|
"project_name": project.name if project else None,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_voice_task_rate_limit_retry_after(workspace, user, scope, now=None):
|
|
|
|
|
def get_voice_task_rate_limit_retry_after(workspace, user, project, scope, window_seconds, now=None):
|
|
|
|
|
now = now or timezone.now()
|
|
|
|
|
window_start = now - timedelta(seconds=VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
|
sessions = VoiceTaskSession.objects.filter(
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
created_at__gte=window_start,
|
|
|
|
|
created_at__lte=now,
|
|
|
|
|
).exclude(error_code__in=VOICE_TASK_RATE_LIMIT_ERROR_CODES)
|
|
|
|
|
sessions = get_voice_task_limit_sessions(workspace, window_seconds, now=now)
|
|
|
|
|
|
|
|
|
|
if scope == "user":
|
|
|
|
|
sessions = sessions.filter(user=user)
|
|
|
|
|
elif scope == "project":
|
|
|
|
|
sessions = sessions.filter(project=project)
|
|
|
|
|
|
|
|
|
|
oldest_session_at = sessions.order_by("created_at").values_list("created_at", flat=True).first()
|
|
|
|
|
reset_at = (oldest_session_at or now) + timedelta(seconds=VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS)
|
|
|
|
|
reset_at = (oldest_session_at or now) + timedelta(seconds=window_seconds)
|
|
|
|
|
retry_after = max(1, math.ceil((reset_at - now).total_seconds()))
|
|
|
|
|
|
|
|
|
|
return retry_after, reset_at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_voice_task_rate_limit_error(workspace, user, ai_settings, now=None):
|
|
|
|
|
def get_voice_task_rate_limit_error(workspace, user, project, ai_settings, now=None):
|
|
|
|
|
now = now or timezone.now()
|
|
|
|
|
state = get_voice_task_rate_limit_state(workspace, user, ai_settings, now=now)
|
|
|
|
|
state = get_voice_task_rate_limit_state(workspace, user, project, ai_settings, now=now)
|
|
|
|
|
|
|
|
|
|
if state["user"]["exceeded"]:
|
|
|
|
|
retry_after, reset_at = get_voice_task_rate_limit_retry_after(workspace, user, "user", now=now)
|
|
|
|
|
for key in ("user_hourly", "workspace_hourly", "user_daily", "workspace_daily", "project_daily"):
|
|
|
|
|
limit_state = state[key]
|
|
|
|
|
if not limit_state["exceeded"]:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
retry_after, reset_at = get_voice_task_rate_limit_retry_after(
|
|
|
|
|
workspace,
|
|
|
|
|
user,
|
|
|
|
|
project,
|
|
|
|
|
limit_state["scope"],
|
|
|
|
|
limit_state["window_seconds"],
|
|
|
|
|
now=now,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"code": "voice_task_user_hourly_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker user hourly limit exceeded.",
|
|
|
|
|
"scope": "user",
|
|
|
|
|
"limit": state["user"]["limit"],
|
|
|
|
|
"used": state["user"]["used"],
|
|
|
|
|
"window_seconds": state["window_seconds"],
|
|
|
|
|
"retry_after": retry_after,
|
|
|
|
|
"reset_at": reset_at,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state["workspace"]["exceeded"]:
|
|
|
|
|
retry_after, reset_at = get_voice_task_rate_limit_retry_after(workspace, user, "workspace", now=now)
|
|
|
|
|
return {
|
|
|
|
|
"code": "voice_task_workspace_hourly_limit_exceeded",
|
|
|
|
|
"message": "Voice Tasker workspace hourly limit exceeded.",
|
|
|
|
|
"scope": "workspace",
|
|
|
|
|
"limit": state["workspace"]["limit"],
|
|
|
|
|
"used": state["workspace"]["used"],
|
|
|
|
|
"window_seconds": state["window_seconds"],
|
|
|
|
|
"code": limit_state["code"],
|
|
|
|
|
"message": limit_state["message"],
|
|
|
|
|
"scope": limit_state["scope"],
|
|
|
|
|
"window": limit_state["window"],
|
|
|
|
|
"limit": limit_state["limit"],
|
|
|
|
|
"used": limit_state["used"],
|
|
|
|
|
"window_seconds": limit_state["window_seconds"],
|
|
|
|
|
"retry_after": retry_after,
|
|
|
|
|
"reset_at": reset_at,
|
|
|
|
|
"project_id": limit_state.get("project_id"),
|
|
|
|
|
"project_identifier": limit_state.get("project_identifier"),
|
|
|
|
|
"project_name": limit_state.get("project_name"),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
@@ -405,10 +488,12 @@ def create_voice_task_rate_limit_session(
|
|
|
|
|
duration_seconds,
|
|
|
|
|
client_context,
|
|
|
|
|
rate_limit_error,
|
|
|
|
|
project=None,
|
|
|
|
|
):
|
|
|
|
|
return VoiceTaskSession.objects.create(
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
user=user,
|
|
|
|
|
project=project,
|
|
|
|
|
status=VoiceTaskSession.Status.FAILED,
|
|
|
|
|
audio_duration_seconds=duration_seconds,
|
|
|
|
|
audio_content_type=audio_content_type,
|
|
|
|
@@ -2495,7 +2580,8 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
@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, project_id=get_request_project_id(request))
|
|
|
|
|
request_project_id = get_request_project_id(request)
|
|
|
|
|
preflight = get_voice_task_preflight(workspace, request.user, project_id=request_project_id)
|
|
|
|
|
|
|
|
|
|
if not preflight["available"]:
|
|
|
|
|
response_status = (
|
|
|
|
@@ -2558,7 +2644,8 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
status=status.HTTP_400_BAD_REQUEST,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
rate_limit_error = get_voice_task_rate_limit_error(workspace, request.user, ai_settings)
|
|
|
|
|
quota_project = get_voice_task_quota_project(workspace, project_id=request_project_id, ai_settings=ai_settings)
|
|
|
|
|
rate_limit_error = get_voice_task_rate_limit_error(workspace, request.user, quota_project, ai_settings)
|
|
|
|
|
if rate_limit_error:
|
|
|
|
|
voice_session = create_voice_task_rate_limit_session(
|
|
|
|
|
workspace=workspace,
|
|
|
|
@@ -2568,6 +2655,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
duration_seconds=duration_seconds,
|
|
|
|
|
client_context=client_context,
|
|
|
|
|
rate_limit_error=rate_limit_error,
|
|
|
|
|
project=quota_project,
|
|
|
|
|
)
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
@@ -2576,11 +2664,15 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
"code": rate_limit_error["code"],
|
|
|
|
|
"error": rate_limit_error["message"],
|
|
|
|
|
"limit_scope": rate_limit_error["scope"],
|
|
|
|
|
"limit_window": rate_limit_error["window"],
|
|
|
|
|
"limit": rate_limit_error["limit"],
|
|
|
|
|
"used": rate_limit_error["used"],
|
|
|
|
|
"window_seconds": rate_limit_error["window_seconds"],
|
|
|
|
|
"retry_after": rate_limit_error["retry_after"],
|
|
|
|
|
"reset_at": rate_limit_error["reset_at"].isoformat(),
|
|
|
|
|
"project_id": rate_limit_error["project_id"],
|
|
|
|
|
"project_identifier": rate_limit_error["project_identifier"],
|
|
|
|
|
"project_name": rate_limit_error["project_name"],
|
|
|
|
|
},
|
|
|
|
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
|
|
|
)
|
|
|
|
@@ -2588,6 +2680,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
voice_session = VoiceTaskSession.objects.create(
|
|
|
|
|
workspace=workspace,
|
|
|
|
|
user=request.user,
|
|
|
|
|
project=quota_project,
|
|
|
|
|
status=VoiceTaskSession.Status.UPLOADED,
|
|
|
|
|
audio_duration_seconds=duration_seconds,
|
|
|
|
|
audio_content_type=audio_content_type,
|
|
|
|
@@ -2652,7 +2745,9 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
|
|
|
|
voice_session.status = VoiceTaskSession.Status.PARSED
|
|
|
|
|
voice_session.intent = parsed["intent"]
|
|
|
|
|
voice_session.parsed_json = parsed
|
|
|
|
|
voice_session.save(update_fields=["status", "intent", "parsed_json", "updated_at"])
|
|
|
|
|
if not voice_session.project_id and parsed.get("project_id"):
|
|
|
|
|
voice_session.project = get_voice_task_quota_project(workspace, project_id=parsed.get("project_id"))
|
|
|
|
|
voice_session.save(update_fields=["status", "intent", "parsed_json", "project", "updated_at"])
|
|
|
|
|
|
|
|
|
|
return Response(
|
|
|
|
|
{
|
|
|
|
@@ -2832,8 +2927,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|
|
|
|
serializer.save()
|
|
|
|
|
issue.refresh_from_db()
|
|
|
|
|
voice_session.created_task = issue
|
|
|
|
|
voice_session.project = project
|
|
|
|
|
voice_session.parsed_json = draft
|
|
|
|
|
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
|
|
|
|
voice_session.save(update_fields=["created_task", "project", "parsed_json", "updated_at"])
|
|
|
|
|
|
|
|
|
|
issue_activity.delay(
|
|
|
|
|
type="issue.activity.updated",
|
|
|
|
@@ -2891,8 +2987,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|
|
|
|
|
|
|
|
|
issue = serializer.save(created_by_id=request.user.id)
|
|
|
|
|
voice_session.created_task = issue
|
|
|
|
|
voice_session.project = project
|
|
|
|
|
voice_session.parsed_json = draft
|
|
|
|
|
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
|
|
|
|
voice_session.save(update_fields=["created_task", "project", "parsed_json", "updated_at"])
|
|
|
|
|
|
|
|
|
|
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
|
|
|
|
|
issue_activity.delay(
|
|
|
|
@@ -2983,8 +3080,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|
|
|
|
serializer.save()
|
|
|
|
|
issue.refresh_from_db()
|
|
|
|
|
voice_session.updated_task = issue
|
|
|
|
|
voice_session.project = project
|
|
|
|
|
voice_session.parsed_json = draft
|
|
|
|
|
voice_session.save(update_fields=["updated_task", "parsed_json", "updated_at"])
|
|
|
|
|
voice_session.save(update_fields=["updated_task", "project", "parsed_json", "updated_at"])
|
|
|
|
|
|
|
|
|
|
issue_activity.delay(
|
|
|
|
|
type="issue.activity.updated",
|
|
|
|
@@ -3037,8 +3135,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
|
|
|
|
entity_name="issue",
|
|
|
|
|
).delete(soft=False)
|
|
|
|
|
voice_session.updated_task = issue
|
|
|
|
|
voice_session.project = project
|
|
|
|
|
voice_session.parsed_json = draft
|
|
|
|
|
voice_session.save(update_fields=["updated_task", "parsed_json", "updated_at"])
|
|
|
|
|
voice_session.save(update_fields=["updated_task", "project", "parsed_json", "updated_at"])
|
|
|
|
|
|
|
|
|
|
issue_activity.delay(
|
|
|
|
|
type="issue.activity.deleted",
|
|
|
|
|