ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: лимиты Voice Tasker до OpenAI pipeline

This commit is contained in:
DCCONSTRUCTIONS 2026-04-28 14:06:25 +03:00
parent 46e27a326c
commit a0c0db27f3
4 changed files with 209 additions and 17 deletions

View File

@ -81,27 +81,35 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован
## Ведение карточек задач Codex
Карточка задачи должна разделять постановку и ход работ.
Карточка задачи должна разделять постановку, план этапов и фактическую реализацию.
Заголовок карточки:
- передает основную суть задачи
- должен быть коротким, читаемым в списке и без служебного шума
Основное тело карточки:
- хранит общее описание задачи, цель, контекст, ограничения и критерии приемки
- не превращается в журнал работ
- хранит концептуальное описание задачи, цель, контекст, ограничения и критерии приемки
- описывает, зачем делается изменение и как система должна работать на среднем уровне детализации
- не превращается в журнал работ и не дублирует чекеры
- остается читаемым входом в задачу после нескольких итераций
Подэлемент `Текущий статус работ`:
- создается как текстовый блок через `Добавить подэлемент`
- хранит фактический отчет по реализации
- обновляется после каждого осмысленного этапа
- фиксирует, что сделано, что проверено, какие файлы/модули затронуты, что осталось и какой следующий шаг
Подэлементы-чекеры:
- создаются через `Добавить подэлемент` как отдельный чекер на каждый смысловой этап
- заголовок чекера должен называться как этап, например `Этап 1. Backend enforcement лимитов`
- пункты внутри чекера должны быть короткими проверяемыми действиями по этапу
- пункт закрывается только после реализации и проверки, а не по намерению
- чекеры используются как рабочий план, а не как место для длинных объяснений
Подэлемент-чекер:
- используется для подзадач, которые можно проверить отдельно
- содержит короткие конкретные пункты без дублирования основного описания
- закрывается по факту реализации и проверки, а не по намерению
Текстовые блоки фактической реализации:
- создаются через `Добавить подэлемент` под соответствующим чекером этапа
- заголовок текстового блока должен явно связывать его с этапом, например `Реализация этапа 1`
- блок фиксирует, что реально сделано, какие файлы/модули затронуты, какие проверки прошли и какие ограничения остались
- важные нюансы для дальнейшего масштабирования записываются именно сюда, а не теряются в чате
- после каждого осмысленного этапа соответствующий текстовый блок обновляется
Статус карточки:
- `В работе` ставится только когда задача реально взята в исполнение
- `Готово` ставится после проверки результата
- `Готово` ставится после проверки результата и закрытия рабочих чекеров
- `Отложено` используется для задач, которые больше не входят в текущий рабочий план
- backlog не должен хранить уже сделанные или сознательно отложенные задачи как активные

View File

@ -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,

View File

@ -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) {

View File

@ -192,6 +192,12 @@ export type TVoiceTaskUploadResult = {
size: number;
};
client_context?: Record<string, unknown>;
limit_scope?: "user" | "workspace";
limit?: number;
used?: number;
window_seconds?: number;
retry_after?: number;
reset_at?: string;
code?: string;
error?: string;
};