diff --git a/AGENTS.md b/AGENTS.md index 70ae801..235a664 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,27 +81,35 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован ## Ведение карточек задач Codex -Карточка задачи должна разделять постановку и ход работ. +Карточка задачи должна разделять постановку, план этапов и фактическую реализацию. + +Заголовок карточки: +- передает основную суть задачи +- должен быть коротким, читаемым в списке и без служебного шума Основное тело карточки: -- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки -- не превращается в журнал работ +- хранит концептуальное описание задачи, цель, контекст, ограничения и критерии приемки +- описывает, зачем делается изменение и как система должна работать на среднем уровне детализации +- не превращается в журнал работ и не дублирует чекеры - остается читаемым входом в задачу после нескольких итераций -Подэлемент `Текущий статус работ`: -- создается как текстовый блок через `Добавить подэлемент` -- хранит фактический отчет по реализации -- обновляется после каждого осмысленного этапа -- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг +Подэлементы-чекеры: +- создаются через `Добавить подэлемент` как отдельный чекер на каждый смысловой этап +- заголовок чекера должен называться как этап, например `Этап 1. Backend enforcement лимитов` +- пункты внутри чекера должны быть короткими проверяемыми действиями по этапу +- пункт закрывается только после реализации и проверки, а не по намерению +- чекеры используются как рабочий план, а не как место для длинных объяснений -Подэлемент-чекер: -- используется для подзадач, которые можно проверить отдельно -- содержит короткие конкретные пункты без дублирования основного описания -- закрывается по факту реализации и проверки, а не по намерению +Текстовые блоки фактической реализации: +- создаются через `Добавить подэлемент` под соответствующим чекером этапа +- заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1` +- блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались +- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате +- после каждого осмысленного этапа соответствующий текстовый блок обновляется Статус карточки: - `В работе` ставится только когда задача реально взята в исполнение -- `Готово` ставится после проверки результата +- `Готово` ставится после проверки результата и закрытия рабочих чекеров - `Отложено` используется для задач, которые больше не входят в текущий рабочий план - backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index c34da2f..1f99d6f 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -4,6 +4,7 @@ import calendar import json +import math import re import uuid from datetime import date, timedelta @@ -60,6 +61,11 @@ 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_ERROR_CODES = { + "voice_task_user_hourly_limit_exceeded", + "voice_task_workspace_hourly_limit_exceeded", +} VOICE_TASK_PROJECT_MATCH_THRESHOLD = 0.8 VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD = 0.8 VOICE_TASK_STATE_MATCH_THRESHOLD = 0.8 @@ -308,6 +314,111 @@ 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): + 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) + 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) + + 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), + }, + "workspace": { + "used": workspace_used, + "limit": workspace_limit, + "exceeded": bool(workspace_limit and workspace_used >= workspace_limit), + }, + } + + +def get_voice_task_rate_limit_retry_after(workspace, user, scope, 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) + + if scope == "user": + sessions = sessions.filter(user=user) + + 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) + 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): + now = now or timezone.now() + state = get_voice_task_rate_limit_state(workspace, user, ai_settings, now=now) + + if state["user"]["exceeded"]: + retry_after, reset_at = get_voice_task_rate_limit_retry_after(workspace, user, "user", 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"], + "retry_after": retry_after, + "reset_at": reset_at, + } + + return None + + +def create_voice_task_rate_limit_session( + workspace, + user, + audio, + audio_content_type, + duration_seconds, + client_context, + rate_limit_error, +): + return VoiceTaskSession.objects.create( + workspace=workspace, + user=user, + status=VoiceTaskSession.Status.FAILED, + audio_duration_seconds=duration_seconds, + audio_content_type=audio_content_type, + audio_size=getattr(audio, "size", None), + client_context=client_context, + error_code=rate_limit_error["code"], + error_message=rate_limit_error["message"], + ) + + class VoiceTaskerPipelineError(Exception): def __init__(self, code, message, response_status=status.HTTP_400_BAD_REQUEST): self.code = code @@ -2440,6 +2551,40 @@ class VoiceTaskParseEndpoint(BaseAPIView): if not isinstance(client_context, dict): client_context = {} + ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first() + if not ai_settings: + return Response( + {"ok": False, "code": "not_configured", "error": "Voice Tasker is not configured for this workspace."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + rate_limit_error = get_voice_task_rate_limit_error(workspace, request.user, ai_settings) + if rate_limit_error: + voice_session = create_voice_task_rate_limit_session( + workspace=workspace, + user=request.user, + audio=audio, + audio_content_type=audio_content_type, + duration_seconds=duration_seconds, + client_context=client_context, + rate_limit_error=rate_limit_error, + ) + return Response( + { + "ok": False, + "voice_session_id": str(voice_session.id), + "code": rate_limit_error["code"], + "error": rate_limit_error["message"], + "limit_scope": rate_limit_error["scope"], + "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(), + }, + status=status.HTTP_429_TOO_MANY_REQUESTS, + ) + voice_session = VoiceTaskSession.objects.create( workspace=workspace, user=request.user, diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index 79fc10d..77a3780 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -114,6 +114,41 @@ function formatPlaybackTime(value: number) { return formatDuration(value); } +function formatRetryAfter(value: unknown) { + const seconds = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(seconds) || seconds <= 0) return "позже"; + if (seconds < 60) return `через ${Math.ceil(seconds)} сек.`; + + const minutes = Math.ceil(seconds / 60); + if (minutes < 60) return `через ${minutes} мин.`; + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `через ${hours} ч ${remainingMinutes} мин.` : `через ${hours} ч`; +} + +function getVoiceTaskErrorMessage(error: unknown, fallback: string) { + if (typeof error === "object" && error !== null && "code" in error) { + const code = String(error.code); + const retryAfter = "retry_after" in error ? formatRetryAfter(error.retry_after) : "позже"; + 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 usageText = used !== null && limit !== null ? ` Использовано ${used} из ${limit} за последний час.` : ""; + + if (code === "voice_task_user_hourly_limit_exceeded") { + return `Лимит Voice Tasker для пользователя исчерпан. Повторите ${retryAfter}.${usageText}`; + } + + if (code === "voice_task_workspace_hourly_limit_exceeded") { + return `Лимит Voice Tasker для workspace исчерпан. Повторите ${retryAfter}.${usageText}`; + } + + if ("error" in error && error.error) return String(error.error); + } + + return fallback; +} + function getCurrentProjectId() { if (typeof window === "undefined") return null; const match = window.location.pathname.match(/\/projects\/([^/]+)/); @@ -1042,8 +1077,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { }); } } catch (err) { - const message = - typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио."; + const message = getVoiceTaskErrorMessage(err, "Не удалось отправить аудио."); setError(message); setStatus("error"); setToast({ @@ -1127,8 +1161,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { if (closeOnSuccess) handleClose(); return result; } catch (err) { - const message = - typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task."; + const message = getVoiceTaskErrorMessage(err, "Не удалось применить Voice Task."); setError(message); setStatus("success"); if (notify) { diff --git a/plane-src/packages/types/src/ai.ts b/plane-src/packages/types/src/ai.ts index 1c1ed8d..b8db04c 100644 --- a/plane-src/packages/types/src/ai.ts +++ b/plane-src/packages/types/src/ai.ts @@ -192,6 +192,12 @@ export type TVoiceTaskUploadResult = { size: number; }; client_context?: Record; + limit_scope?: "user" | "workspace"; + limit?: number; + used?: number; + window_seconds?: number; + retry_after?: number; + reset_at?: string; code?: string; error?: string; };