ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: суточные квоты Voice Tasker по workspace и контурам
This commit is contained in:
parent
a0c0db27f3
commit
a0213db2fc
|
|
@ -33,6 +33,9 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
"max_audio_duration_seconds",
|
"max_audio_duration_seconds",
|
||||||
"per_user_hourly_limit",
|
"per_user_hourly_limit",
|
||||||
"workspace_hourly_limit",
|
"workspace_hourly_limit",
|
||||||
|
"per_user_daily_limit",
|
||||||
|
"workspace_daily_limit",
|
||||||
|
"project_daily_limit",
|
||||||
"credential",
|
"credential",
|
||||||
"openai_api_key",
|
"openai_api_key",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
|
@ -105,6 +108,21 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
|
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def validate_per_user_daily_limit(self, value):
|
||||||
|
if value < 1 or value > 10000:
|
||||||
|
raise serializers.ValidationError("Per-user daily limit must be between 1 and 10000.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_workspace_daily_limit(self, value):
|
||||||
|
if value < 1 or value > 100000:
|
||||||
|
raise serializers.ValidationError("Workspace daily limit must be between 1 and 100000.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def validate_project_daily_limit(self, value):
|
||||||
|
if value < 1 or value > 50000:
|
||||||
|
raise serializers.ValidationError("Project daily limit must be between 1 and 50000.")
|
||||||
|
return value
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
api_key = validated_data.pop("openai_api_key", None)
|
api_key = validated_data.pop("openai_api_key", None)
|
||||||
default_project_id = validated_data.pop("default_project_id", serializers.empty)
|
default_project_id = validated_data.pop("default_project_id", serializers.empty)
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,14 @@ VOICE_TASK_INTENTS = {"create_task", "update_task", "delete_task", "unknown"}
|
||||||
VOICE_TASK_PRIORITIES = {"none", "low", "medium", "high", "urgent"}
|
VOICE_TASK_PRIORITIES = {"none", "low", "medium", "high", "urgent"}
|
||||||
VOICE_TASK_MEMORY_LIMIT = 5
|
VOICE_TASK_MEMORY_LIMIT = 5
|
||||||
VOICE_TASK_CONTEXT_LIMIT = 100
|
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_RATE_LIMIT_ERROR_CODES = {
|
||||||
"voice_task_user_hourly_limit_exceeded",
|
"voice_task_user_hourly_limit_exceeded",
|
||||||
"voice_task_workspace_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_PROJECT_MATCH_THRESHOLD = 0.8
|
||||||
VOICE_TASK_ASSIGNEE_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
|
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()
|
now = now or timezone.now()
|
||||||
window_start = now - timedelta(seconds=VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS)
|
window_start = now - timedelta(seconds=window_seconds)
|
||||||
sessions = VoiceTaskSession.objects.filter(
|
return VoiceTaskSession.objects.filter(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
created_at__gte=window_start,
|
created_at__gte=window_start,
|
||||||
created_at__lte=now,
|
created_at__lte=now,
|
||||||
).exclude(error_code__in=VOICE_TASK_RATE_LIMIT_ERROR_CODES)
|
).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()
|
def get_voice_task_rate_limit_state(workspace, user, project, ai_settings, now=None):
|
||||||
user_limit = max(int(ai_settings.per_user_hourly_limit or 0), 0)
|
hourly_sessions = get_voice_task_limit_sessions(
|
||||||
workspace_limit = max(int(ai_settings.workspace_hourly_limit or 0), 0)
|
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 {
|
return {
|
||||||
"window_start": window_start,
|
"user_hourly": {
|
||||||
"window_seconds": VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS,
|
"scope": "user",
|
||||||
"user": {
|
"window": "hour",
|
||||||
"used": user_used,
|
"used": user_hourly_used,
|
||||||
"limit": user_limit,
|
"limit": user_hourly_limit,
|
||||||
"exceeded": bool(user_limit and user_used >= user_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": {
|
"workspace_hourly": {
|
||||||
"used": workspace_used,
|
"scope": "workspace",
|
||||||
"limit": workspace_limit,
|
"window": "hour",
|
||||||
"exceeded": bool(workspace_limit and workspace_used >= workspace_limit),
|
"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()
|
now = now or timezone.now()
|
||||||
window_start = now - timedelta(seconds=VOICE_TASK_RATE_LIMIT_WINDOW_SECONDS)
|
sessions = get_voice_task_limit_sessions(workspace, window_seconds, now=now)
|
||||||
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)
|
|
||||||
|
|
||||||
if scope == "user":
|
if scope == "user":
|
||||||
sessions = sessions.filter(user=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()
|
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()))
|
retry_after = max(1, math.ceil((reset_at - now).total_seconds()))
|
||||||
|
|
||||||
return retry_after, reset_at
|
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()
|
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"]:
|
for key in ("user_hourly", "workspace_hourly", "user_daily", "workspace_daily", "project_daily"):
|
||||||
retry_after, reset_at = get_voice_task_rate_limit_retry_after(workspace, user, "user", now=now)
|
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 {
|
return {
|
||||||
"code": "voice_task_user_hourly_limit_exceeded",
|
"code": limit_state["code"],
|
||||||
"message": "Voice Tasker user hourly limit exceeded.",
|
"message": limit_state["message"],
|
||||||
"scope": "user",
|
"scope": limit_state["scope"],
|
||||||
"limit": state["user"]["limit"],
|
"window": limit_state["window"],
|
||||||
"used": state["user"]["used"],
|
"limit": limit_state["limit"],
|
||||||
"window_seconds": state["window_seconds"],
|
"used": limit_state["used"],
|
||||||
"retry_after": retry_after,
|
"window_seconds": limit_state["window_seconds"],
|
||||||
"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"],
|
|
||||||
"retry_after": retry_after,
|
"retry_after": retry_after,
|
||||||
"reset_at": reset_at,
|
"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
|
return None
|
||||||
|
|
@ -405,10 +488,12 @@ def create_voice_task_rate_limit_session(
|
||||||
duration_seconds,
|
duration_seconds,
|
||||||
client_context,
|
client_context,
|
||||||
rate_limit_error,
|
rate_limit_error,
|
||||||
|
project=None,
|
||||||
):
|
):
|
||||||
return VoiceTaskSession.objects.create(
|
return VoiceTaskSession.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=user,
|
user=user,
|
||||||
|
project=project,
|
||||||
status=VoiceTaskSession.Status.FAILED,
|
status=VoiceTaskSession.Status.FAILED,
|
||||||
audio_duration_seconds=duration_seconds,
|
audio_duration_seconds=duration_seconds,
|
||||||
audio_content_type=audio_content_type,
|
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")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
workspace = Workspace.objects.get(slug=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"]:
|
if not preflight["available"]:
|
||||||
response_status = (
|
response_status = (
|
||||||
|
|
@ -2558,7 +2644,8 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
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:
|
if rate_limit_error:
|
||||||
voice_session = create_voice_task_rate_limit_session(
|
voice_session = create_voice_task_rate_limit_session(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
|
|
@ -2568,6 +2655,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
client_context=client_context,
|
client_context=client_context,
|
||||||
rate_limit_error=rate_limit_error,
|
rate_limit_error=rate_limit_error,
|
||||||
|
project=quota_project,
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
@ -2576,11 +2664,15 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
"code": rate_limit_error["code"],
|
"code": rate_limit_error["code"],
|
||||||
"error": rate_limit_error["message"],
|
"error": rate_limit_error["message"],
|
||||||
"limit_scope": rate_limit_error["scope"],
|
"limit_scope": rate_limit_error["scope"],
|
||||||
|
"limit_window": rate_limit_error["window"],
|
||||||
"limit": rate_limit_error["limit"],
|
"limit": rate_limit_error["limit"],
|
||||||
"used": rate_limit_error["used"],
|
"used": rate_limit_error["used"],
|
||||||
"window_seconds": rate_limit_error["window_seconds"],
|
"window_seconds": rate_limit_error["window_seconds"],
|
||||||
"retry_after": rate_limit_error["retry_after"],
|
"retry_after": rate_limit_error["retry_after"],
|
||||||
"reset_at": rate_limit_error["reset_at"].isoformat(),
|
"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,
|
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
)
|
)
|
||||||
|
|
@ -2588,6 +2680,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
voice_session = VoiceTaskSession.objects.create(
|
voice_session = VoiceTaskSession.objects.create(
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
|
project=quota_project,
|
||||||
status=VoiceTaskSession.Status.UPLOADED,
|
status=VoiceTaskSession.Status.UPLOADED,
|
||||||
audio_duration_seconds=duration_seconds,
|
audio_duration_seconds=duration_seconds,
|
||||||
audio_content_type=audio_content_type,
|
audio_content_type=audio_content_type,
|
||||||
|
|
@ -2652,7 +2745,9 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
voice_session.status = VoiceTaskSession.Status.PARSED
|
voice_session.status = VoiceTaskSession.Status.PARSED
|
||||||
voice_session.intent = parsed["intent"]
|
voice_session.intent = parsed["intent"]
|
||||||
voice_session.parsed_json = parsed
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|
@ -2832,8 +2927,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
serializer.save()
|
serializer.save()
|
||||||
issue.refresh_from_db()
|
issue.refresh_from_db()
|
||||||
voice_session.created_task = issue
|
voice_session.created_task = issue
|
||||||
|
voice_session.project = project
|
||||||
voice_session.parsed_json = draft
|
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(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
|
|
@ -2891,8 +2987,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
|
|
||||||
issue = serializer.save(created_by_id=request.user.id)
|
issue = serializer.save(created_by_id=request.user.id)
|
||||||
voice_session.created_task = issue
|
voice_session.created_task = issue
|
||||||
|
voice_session.project = project
|
||||||
voice_session.parsed_json = draft
|
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)
|
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
|
|
@ -2983,8 +3080,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
serializer.save()
|
serializer.save()
|
||||||
issue.refresh_from_db()
|
issue.refresh_from_db()
|
||||||
voice_session.updated_task = issue
|
voice_session.updated_task = issue
|
||||||
|
voice_session.project = project
|
||||||
voice_session.parsed_json = draft
|
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(
|
issue_activity.delay(
|
||||||
type="issue.activity.updated",
|
type="issue.activity.updated",
|
||||||
|
|
@ -3037,8 +3135,9 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
entity_name="issue",
|
entity_name="issue",
|
||||||
).delete(soft=False)
|
).delete(soft=False)
|
||||||
voice_session.updated_task = issue
|
voice_session.updated_task = issue
|
||||||
|
voice_session.project = project
|
||||||
voice_session.parsed_json = draft
|
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(
|
issue_activity.delay(
|
||||||
type="issue.activity.deleted",
|
type="issue.activity.deleted",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
# Generated by Codex on 2026-04-28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("db", "0130_project_storage_quotas"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workspaceaicredential",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workspaceaisettings",
|
||||||
|
name="deleted_at",
|
||||||
|
field=models.DateTimeField(blank=True, null=True, verbose_name="Deleted At"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspaceaisettings",
|
||||||
|
name="per_user_daily_limit",
|
||||||
|
field=models.PositiveIntegerField(default=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspaceaisettings",
|
||||||
|
name="workspace_daily_limit",
|
||||||
|
field=models.PositiveIntegerField(default=1000),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="workspaceaisettings",
|
||||||
|
name="project_daily_limit",
|
||||||
|
field=models.PositiveIntegerField(default=300),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="voicetasksession",
|
||||||
|
name="project",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="voice_task_sessions",
|
||||||
|
to="db.project",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="voicetasksession",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["workspace", "project", "-created_at"],
|
||||||
|
name="voice_task_session_project_idx",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -52,6 +52,9 @@ class WorkspaceAISettings(BaseModel):
|
||||||
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
|
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
|
||||||
per_user_hourly_limit = models.PositiveIntegerField(default=30)
|
per_user_hourly_limit = models.PositiveIntegerField(default=30)
|
||||||
workspace_hourly_limit = models.PositiveIntegerField(default=300)
|
workspace_hourly_limit = models.PositiveIntegerField(default=300)
|
||||||
|
per_user_daily_limit = models.PositiveIntegerField(default=100)
|
||||||
|
workspace_daily_limit = models.PositiveIntegerField(default=1000)
|
||||||
|
project_daily_limit = models.PositiveIntegerField(default=300)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Workspace AI Settings"
|
verbose_name = "Workspace AI Settings"
|
||||||
|
|
@ -106,6 +109,13 @@ class VoiceTaskSession(BaseModel):
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="voice_task_sessions",
|
related_name="voice_task_sessions",
|
||||||
)
|
)
|
||||||
|
project = models.ForeignKey(
|
||||||
|
"db.Project",
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="voice_task_sessions",
|
||||||
|
)
|
||||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.UPLOADED)
|
status = models.CharField(max_length=32, choices=Status.choices, default=Status.UPLOADED)
|
||||||
audio_duration_seconds = models.FloatField(null=True, blank=True)
|
audio_duration_seconds = models.FloatField(null=True, blank=True)
|
||||||
audio_content_type = models.CharField(max_length=100, blank=True)
|
audio_content_type = models.CharField(max_length=100, blank=True)
|
||||||
|
|
@ -138,6 +148,7 @@ class VoiceTaskSession(BaseModel):
|
||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["workspace", "user", "-created_at"], name="voice_task_session_user_idx"),
|
models.Index(fields=["workspace", "user", "-created_at"], name="voice_task_session_user_idx"),
|
||||||
|
models.Index(fields=["workspace", "project", "-created_at"], name="voice_task_session_project_idx"),
|
||||||
models.Index(fields=["workspace", "status", "-created_at"], name="voice_task_session_status_idx"),
|
models.Index(fields=["workspace", "status", "-created_at"], name="voice_task_session_status_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,9 @@ function getVoiceTaskErrorMessage(error: unknown, fallback: string) {
|
||||||
const retryAfter = "retry_after" in error ? formatRetryAfter(error.retry_after) : "позже";
|
const retryAfter = "retry_after" in error ? formatRetryAfter(error.retry_after) : "позже";
|
||||||
const used = "used" in error && typeof error.used === "number" ? error.used : null;
|
const used = "used" in error && typeof error.used === "number" ? error.used : null;
|
||||||
const limit = "limit" in error && typeof error.limit === "number" ? error.limit : null;
|
const limit = "limit" in error && typeof error.limit === "number" ? error.limit : null;
|
||||||
const usageText = used !== null && limit !== null ? ` Использовано ${used} из ${limit} за последний час.` : "";
|
const limitWindow = "limit_window" in error ? String(error.limit_window) : "hour";
|
||||||
|
const windowText = limitWindow === "day" ? "за последние сутки" : "за последний час";
|
||||||
|
const usageText = used !== null && limit !== null ? ` Использовано ${used} из ${limit} ${windowText}.` : "";
|
||||||
|
|
||||||
if (code === "voice_task_user_hourly_limit_exceeded") {
|
if (code === "voice_task_user_hourly_limit_exceeded") {
|
||||||
return `Лимит Voice Tasker для пользователя исчерпан. Повторите ${retryAfter}.${usageText}`;
|
return `Лимит Voice Tasker для пользователя исчерпан. Повторите ${retryAfter}.${usageText}`;
|
||||||
|
|
@ -143,6 +145,19 @@ function getVoiceTaskErrorMessage(error: unknown, fallback: string) {
|
||||||
return `Лимит Voice Tasker для workspace исчерпан. Повторите ${retryAfter}.${usageText}`;
|
return `Лимит Voice Tasker для workspace исчерпан. Повторите ${retryAfter}.${usageText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (code === "voice_task_user_daily_limit_exceeded") {
|
||||||
|
return `Суточный лимит Voice Tasker для пользователя исчерпан. Повторите ${retryAfter}.${usageText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "voice_task_workspace_daily_limit_exceeded") {
|
||||||
|
return `Суточный лимит Voice Tasker для workspace исчерпан. Повторите ${retryAfter}.${usageText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "voice_task_project_daily_limit_exceeded") {
|
||||||
|
const projectName = "project_name" in error && error.project_name ? ` для контура ${String(error.project_name)}` : "";
|
||||||
|
return `Суточный лимит Voice Tasker${projectName} исчерпан. Повторите ${retryAfter}.${usageText}`;
|
||||||
|
}
|
||||||
|
|
||||||
if ("error" in error && error.error) return String(error.error);
|
if ("error" in error && error.error) return String(error.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ type TFormState = {
|
||||||
max_audio_duration_seconds: number;
|
max_audio_duration_seconds: number;
|
||||||
per_user_hourly_limit: number;
|
per_user_hourly_limit: number;
|
||||||
workspace_hourly_limit: number;
|
workspace_hourly_limit: number;
|
||||||
|
per_user_daily_limit: number;
|
||||||
|
workspace_daily_limit: number;
|
||||||
|
project_daily_limit: number;
|
||||||
openai_api_key: string;
|
openai_api_key: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,6 +68,9 @@ const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({
|
||||||
max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120,
|
max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120,
|
||||||
per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30,
|
per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30,
|
||||||
workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300,
|
workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300,
|
||||||
|
per_user_daily_limit: settings?.per_user_daily_limit ?? 100,
|
||||||
|
workspace_daily_limit: settings?.workspace_daily_limit ?? 1000,
|
||||||
|
project_daily_limit: settings?.project_daily_limit ?? 300,
|
||||||
openai_api_key: "",
|
openai_api_key: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -184,6 +190,9 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
max_audio_duration_seconds: formState.max_audio_duration_seconds,
|
max_audio_duration_seconds: formState.max_audio_duration_seconds,
|
||||||
per_user_hourly_limit: formState.per_user_hourly_limit,
|
per_user_hourly_limit: formState.per_user_hourly_limit,
|
||||||
workspace_hourly_limit: formState.workspace_hourly_limit,
|
workspace_hourly_limit: formState.workspace_hourly_limit,
|
||||||
|
per_user_daily_limit: formState.per_user_daily_limit,
|
||||||
|
workspace_daily_limit: formState.workspace_daily_limit,
|
||||||
|
project_daily_limit: formState.project_daily_limit,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formState.openai_api_key.trim()) payload.openai_api_key = formState.openai_api_key.trim();
|
if (formState.openai_api_key.trim()) payload.openai_api_key = formState.openai_api_key.trim();
|
||||||
|
|
@ -339,7 +348,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
icon={BrainCircuit}
|
icon={BrainCircuit}
|
||||||
title="Models and limits"
|
title="Models and limits"
|
||||||
description="MVP использует один workspace key для транскрибации и структурирования."
|
description="Один workspace key обслуживает всех пользователей. Лимиты срабатывают до отправки аудио в OpenAI."
|
||||||
/>
|
/>
|
||||||
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
||||||
<Field label="Transcription model">
|
<Field label="Transcription model">
|
||||||
|
|
@ -356,24 +365,51 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
className="nodedc-settings-input w-full"
|
className="nodedc-settings-input w-full"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Per-user limit">
|
<Field label="Лимит пользователя за час">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={formState.per_user_hourly_limit}
|
value={formState.per_user_hourly_limit}
|
||||||
min={1}
|
min={1}
|
||||||
max={1000}
|
max={1000}
|
||||||
suffix="tasks/hour"
|
suffix="за час"
|
||||||
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
|
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Workspace limit">
|
<Field label="Лимит workspace за час">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={formState.workspace_hourly_limit}
|
value={formState.workspace_hourly_limit}
|
||||||
min={1}
|
min={1}
|
||||||
max={10000}
|
max={10000}
|
||||||
suffix="tasks/hour"
|
suffix="за час"
|
||||||
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
|
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Суточная квота пользователя">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.per_user_daily_limit}
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
suffix="за сутки"
|
||||||
|
onChange={(value) => updateFormValue("per_user_daily_limit", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Суточная квота workspace">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.workspace_daily_limit}
|
||||||
|
min={1}
|
||||||
|
max={100000}
|
||||||
|
suffix="за сутки"
|
||||||
|
onChange={(value) => updateFormValue("workspace_daily_limit", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Суточная квота контура">
|
||||||
|
<NumberInput
|
||||||
|
value={formState.project_daily_limit}
|
||||||
|
min={1}
|
||||||
|
max={50000}
|
||||||
|
suffix="за сутки"
|
||||||
|
onChange={(value) => updateFormValue("project_daily_limit", value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ export type TWorkspaceAISettings = {
|
||||||
max_audio_duration_seconds: number;
|
max_audio_duration_seconds: number;
|
||||||
per_user_hourly_limit: number;
|
per_user_hourly_limit: number;
|
||||||
workspace_hourly_limit: number;
|
workspace_hourly_limit: number;
|
||||||
|
per_user_daily_limit: number;
|
||||||
|
workspace_daily_limit: number;
|
||||||
|
project_daily_limit: number;
|
||||||
credential: TWorkspaceAICredentialStatus;
|
credential: TWorkspaceAICredentialStatus;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -61,6 +64,9 @@ export type TWorkspaceAISettingsPayload = Partial<
|
||||||
| "max_audio_duration_seconds"
|
| "max_audio_duration_seconds"
|
||||||
| "per_user_hourly_limit"
|
| "per_user_hourly_limit"
|
||||||
| "workspace_hourly_limit"
|
| "workspace_hourly_limit"
|
||||||
|
| "per_user_daily_limit"
|
||||||
|
| "workspace_daily_limit"
|
||||||
|
| "project_daily_limit"
|
||||||
>
|
>
|
||||||
> & {
|
> & {
|
||||||
openai_api_key?: string;
|
openai_api_key?: string;
|
||||||
|
|
@ -192,12 +198,16 @@ export type TVoiceTaskUploadResult = {
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
client_context?: Record<string, unknown>;
|
client_context?: Record<string, unknown>;
|
||||||
limit_scope?: "user" | "workspace";
|
limit_scope?: "user" | "workspace" | "project";
|
||||||
|
limit_window?: "hour" | "day";
|
||||||
limit?: number;
|
limit?: number;
|
||||||
used?: number;
|
used?: number;
|
||||||
window_seconds?: number;
|
window_seconds?: number;
|
||||||
retry_after?: number;
|
retry_after?: number;
|
||||||
reset_at?: string;
|
reset_at?: string;
|
||||||
|
project_id?: string | null;
|
||||||
|
project_identifier?: string | null;
|
||||||
|
project_name?: string | null;
|
||||||
code?: string;
|
code?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue