ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: мониторинг очереди Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS 2026-04-28 16:31:47 +03:00
parent 3b295e33f3
commit d60c28ec04
8 changed files with 772 additions and 10 deletions

View File

@ -6,6 +6,7 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
VoiceTaskCommitEndpoint, VoiceTaskCommitEndpoint,
VoiceTaskMonitorEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
VoiceTaskSessionEndpoint, VoiceTaskSessionEndpoint,
@ -25,6 +26,11 @@ urlpatterns = [
WorkspaceAISettingsTestConnectionEndpoint.as_view(), WorkspaceAISettingsTestConnectionEndpoint.as_view(),
name="voice-tasker-settings-test-connection", name="voice-tasker-settings-test-connection",
), ),
path(
"workspaces/<str:slug>/voice-tasker/monitor/",
VoiceTaskMonitorEndpoint.as_view(),
name="voice-tasker-monitor",
),
path( path(
"workspaces/<str:slug>/voice-task/preflight/", "workspaces/<str:slug>/voice-task/preflight/",
VoiceTaskPreflightEndpoint.as_view(), VoiceTaskPreflightEndpoint.as_view(),

View File

@ -251,6 +251,7 @@ from .webhook.base import (
from .voice_tasker import ( from .voice_tasker import (
VoiceTaskCommitEndpoint, VoiceTaskCommitEndpoint,
VoiceTaskMonitorEndpoint,
VoiceTaskParseEndpoint, VoiceTaskParseEndpoint,
VoiceTaskPreflightEndpoint, VoiceTaskPreflightEndpoint,
VoiceTaskSessionEndpoint, VoiceTaskSessionEndpoint,

View File

@ -16,7 +16,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import connection, transaction from django.db import connection, transaction
from django.db.models import Max, Q from django.db.models import Avg, Count, Max, Q, Sum
from django.utils.text import get_valid_filename from django.utils.text import get_valid_filename
from django.utils import timezone from django.utils import timezone
@ -68,6 +68,8 @@ VOICE_TASK_RATE_LIMIT_HOURLY_WINDOW_SECONDS = 60 * 60
VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS = 24 * 60 * 60 VOICE_TASK_RATE_LIMIT_DAILY_WINDOW_SECONDS = 24 * 60 * 60
VOICE_TASK_CONCURRENCY_RETRY_AFTER_SECONDS = 15 VOICE_TASK_CONCURRENCY_RETRY_AFTER_SECONDS = 15
VOICE_TASK_ACTIVE_SESSION_STALE_SECONDS = 30 * 60 VOICE_TASK_ACTIVE_SESSION_STALE_SECONDS = 30 * 60
VOICE_TASK_MONITOR_WINDOW_SECONDS = 24 * 60 * 60
VOICE_TASK_MONITOR_RECENT_LIMIT = 20
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",
@ -531,6 +533,14 @@ def clear_voice_task_audio_file(voice_session):
voice_session.save(update_fields=["audio_file", "updated_at"]) voice_session.save(update_fields=["audio_file", "updated_at"])
def get_voice_task_duration_ms(started_at, finished_at=None):
if not started_at:
return None
finished_at = finished_at or timezone.now()
return max(0, int((finished_at - started_at).total_seconds() * 1000))
def get_voice_task_concurrency_state(workspace, ai_settings): def get_voice_task_concurrency_state(workspace, ai_settings):
active_sessions = VoiceTaskSession.objects.filter( active_sessions = VoiceTaskSession.objects.filter(
workspace=workspace, workspace=workspace,
@ -578,6 +588,7 @@ def create_voice_task_rate_limit_session(
user=user, user=user,
project=project, project=project,
status=VoiceTaskSession.Status.FAILED, status=VoiceTaskSession.Status.FAILED,
failed_at=timezone.now(),
audio_duration_seconds=duration_seconds, audio_duration_seconds=duration_seconds,
audio_content_type=audio_content_type, audio_content_type=audio_content_type,
audio_size=getattr(audio, "size", None), audio_size=getattr(audio, "size", None),
@ -602,6 +613,7 @@ def create_voice_task_failed_preflight_session(
user=user, user=user,
project=project, project=project,
status=VoiceTaskSession.Status.FAILED, status=VoiceTaskSession.Status.FAILED,
failed_at=timezone.now(),
audio_duration_seconds=duration_seconds, audio_duration_seconds=duration_seconds,
audio_content_type=audio_content_type, audio_content_type=audio_content_type,
audio_size=getattr(audio, "size", None), audio_size=getattr(audio, "size", None),
@ -666,6 +678,7 @@ def reserve_voice_task_session(
user=user, user=user,
project=quota_project, project=quota_project,
status=VoiceTaskSession.Status.QUEUED, status=VoiceTaskSession.Status.QUEUED,
queued_at=timezone.now(),
audio_duration_seconds=duration_seconds, audio_duration_seconds=duration_seconds,
audio_content_type=audio_content_type, audio_content_type=audio_content_type,
audio_size=getattr(audio, "size", None), audio_size=getattr(audio, "size", None),
@ -772,6 +785,7 @@ class VoiceTaskParserService:
def __init__(self, api_key, model): def __init__(self, api_key, model):
self.client = OpenAI(api_key=api_key) self.client = OpenAI(api_key=api_key)
self.model = model self.model = model
self.last_usage = {}
def parse(self, parser_context): def parse(self, parser_context):
response = self.client.chat.completions.create( response = self.client.chat.completions.create(
@ -822,6 +836,13 @@ class VoiceTaskParserService:
], ],
) )
content = response.choices[0].message.content or "" content = response.choices[0].message.content or ""
usage = getattr(response, "usage", None)
if usage:
self.last_usage = {
"prompt_tokens": getattr(usage, "prompt_tokens", None),
"completion_tokens": getattr(usage, "completion_tokens", None),
"total_tokens": getattr(usage, "total_tokens", None),
}
try: try:
parsed = json.loads(content) parsed = json.loads(content)
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
@ -2671,11 +2692,13 @@ def process_voice_task_session_pipeline(voice_session_id):
ai_settings, api_key = get_workspace_ai_runtime(workspace) ai_settings, api_key = get_workspace_ai_runtime(workspace)
voice_session.status = VoiceTaskSession.Status.PROCESSING voice_session.status = VoiceTaskSession.Status.PROCESSING
voice_session.save(update_fields=["status", "updated_at"]) voice_session.processing_started_at = timezone.now()
voice_session.save(update_fields=["status", "processing_started_at", "updated_at"])
voice_session.status = VoiceTaskSession.Status.TRANSCRIBING voice_session.status = VoiceTaskSession.Status.TRANSCRIBING
voice_session.save(update_fields=["status", "updated_at"]) voice_session.save(update_fields=["status", "updated_at"])
transcription_started_at = timezone.now()
voice_session.audio_file.open("rb") voice_session.audio_file.open("rb")
try: try:
transcript = OpenAITranscriptionService( transcript = OpenAITranscriptionService(
@ -2698,7 +2721,20 @@ def process_voice_task_session_pipeline(voice_session_id):
voice_session.status = VoiceTaskSession.Status.TRANSCRIBED voice_session.status = VoiceTaskSession.Status.TRANSCRIBED
voice_session.transcript = transcript voice_session.transcript = transcript
voice_session.save(update_fields=["status", "transcript", "updated_at"]) voice_session.transcribed_at = timezone.now()
voice_session.transcription_duration_ms = get_voice_task_duration_ms(
transcription_started_at,
voice_session.transcribed_at,
)
voice_session.save(
update_fields=[
"status",
"transcript",
"transcribed_at",
"transcription_duration_ms",
"updated_at",
]
)
parser_context = build_voice_task_parser_context( parser_context = build_voice_task_parser_context(
workspace=workspace, workspace=workspace,
@ -2710,38 +2746,93 @@ def process_voice_task_session_pipeline(voice_session_id):
voice_session.status = VoiceTaskSession.Status.PARSING voice_session.status = VoiceTaskSession.Status.PARSING
voice_session.save(update_fields=["status", "updated_at"]) voice_session.save(update_fields=["status", "updated_at"])
parsed = VoiceTaskParserService( parsing_started_at = timezone.now()
parser_service = VoiceTaskParserService(
api_key=api_key, api_key=api_key,
model=ai_settings.structuring_model, model=ai_settings.structuring_model,
).parse(parser_context) )
parsed = parser_service.parse(parser_context)
parsing_finished_at = timezone.now()
if not parsed.get("state_hint"): if not parsed.get("state_hint"):
inferred_state_hint = infer_voice_task_state_hint(transcript) inferred_state_hint = infer_voice_task_state_hint(transcript)
if inferred_state_hint: if inferred_state_hint:
parsed["state_hint"] = inferred_state_hint parsed["state_hint"] = inferred_state_hint
parsed = harden_voice_task_intent(parsed, transcript) parsed = harden_voice_task_intent(parsed, transcript)
parser_usage = parser_service.last_usage or {}
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.completed_at = timezone.now()
voice_session.parsing_duration_ms = get_voice_task_duration_ms(parsing_started_at, parsing_finished_at)
voice_session.processing_duration_ms = get_voice_task_duration_ms(
voice_session.processing_started_at,
voice_session.completed_at,
)
voice_session.parser_prompt_tokens = parser_usage.get("prompt_tokens")
voice_session.parser_completion_tokens = parser_usage.get("completion_tokens")
voice_session.parser_total_tokens = parser_usage.get("total_tokens")
if not voice_session.project_id and parsed.get("project_id"): 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.project = get_voice_task_quota_project(workspace, project_id=parsed.get("project_id"))
voice_session.error_code = "" voice_session.error_code = ""
voice_session.error_message = "" voice_session.error_message = ""
voice_session.save( voice_session.save(
update_fields=["status", "intent", "parsed_json", "project", "error_code", "error_message", "updated_at"] update_fields=[
"status",
"intent",
"parsed_json",
"project",
"completed_at",
"parsing_duration_ms",
"processing_duration_ms",
"parser_prompt_tokens",
"parser_completion_tokens",
"parser_total_tokens",
"error_code",
"error_message",
"updated_at",
]
) )
except VoiceTaskerPipelineError as exc: except VoiceTaskerPipelineError as exc:
pipeline_error = exc pipeline_error = exc
voice_session.status = VoiceTaskSession.Status.FAILED voice_session.status = VoiceTaskSession.Status.FAILED
voice_session.failed_at = timezone.now()
voice_session.processing_duration_ms = get_voice_task_duration_ms(
voice_session.processing_started_at,
voice_session.failed_at,
)
voice_session.error_code = pipeline_error.code voice_session.error_code = pipeline_error.code
voice_session.error_message = pipeline_error.message voice_session.error_message = pipeline_error.message
voice_session.save(update_fields=["status", "error_code", "error_message", "updated_at"]) voice_session.save(
update_fields=[
"status",
"failed_at",
"processing_duration_ms",
"error_code",
"error_message",
"updated_at",
]
)
except Exception as exc: except Exception as exc:
pipeline_error = get_openai_pipeline_error(exc) pipeline_error = get_openai_pipeline_error(exc)
voice_session.status = VoiceTaskSession.Status.FAILED voice_session.status = VoiceTaskSession.Status.FAILED
voice_session.failed_at = timezone.now()
voice_session.processing_duration_ms = get_voice_task_duration_ms(
voice_session.processing_started_at,
voice_session.failed_at,
)
voice_session.error_code = pipeline_error.code voice_session.error_code = pipeline_error.code
voice_session.error_message = pipeline_error.message voice_session.error_message = pipeline_error.message
voice_session.save(update_fields=["status", "error_code", "error_message", "updated_at"]) voice_session.save(
update_fields=[
"status",
"failed_at",
"processing_duration_ms",
"error_code",
"error_message",
"updated_at",
]
)
finally: finally:
try: try:
clear_voice_task_audio_file(voice_session) clear_voice_task_audio_file(voice_session)
@ -2808,6 +2899,211 @@ def serialize_voice_task_session_response(voice_session):
return base_payload return base_payload
def get_voice_task_stale_threshold():
return timezone.now() - timedelta(seconds=VOICE_TASK_ACTIVE_SESSION_STALE_SECONDS)
def get_voice_task_user_display_name(user):
if not user:
return "Пользователь"
return getattr(user, "display_name", "") or getattr(user, "email", "") or "Пользователь"
def serialize_voice_task_monitor_session(voice_session, stale_threshold=None):
stale_threshold = stale_threshold or get_voice_task_stale_threshold()
user = voice_session.user
project = voice_session.project
created_issue = voice_session.created_task
updated_issue = voice_session.updated_task
issue = created_issue or updated_issue
is_active = voice_session.status in VOICE_TASK_ACTIVE_SESSION_STATUSES
return {
"id": str(voice_session.id),
"status": voice_session.status,
"intent": voice_session.intent,
"is_active": is_active,
"is_stale": bool(is_active and voice_session.updated_at < stale_threshold),
"user": {
"id": str(user.id) if user else None,
"name": get_voice_task_user_display_name(user),
"email": getattr(user, "email", "") if user else "",
},
"project": {
"id": str(project.id) if project else None,
"name": project.name if project else "",
"identifier": project.identifier if project else "",
},
"issue": {
"id": str(issue.id) if issue else None,
"key": f"{issue.project.identifier}-{issue.sequence_id}" if issue and issue.project_id else "",
"name": issue.name if issue else "",
},
"audio": {
"duration_seconds": voice_session.audio_duration_seconds,
"size": voice_session.audio_size,
"content_type": voice_session.audio_content_type,
"has_temp_file": bool(voice_session.audio_file),
},
"timings": {
"queued_at": voice_session.queued_at,
"processing_started_at": voice_session.processing_started_at,
"transcribed_at": voice_session.transcribed_at,
"completed_at": voice_session.completed_at,
"failed_at": voice_session.failed_at,
"transcription_duration_ms": voice_session.transcription_duration_ms,
"parsing_duration_ms": voice_session.parsing_duration_ms,
"processing_duration_ms": voice_session.processing_duration_ms,
"last_update_at": voice_session.updated_at,
"created_at": voice_session.created_at,
},
"usage": {
"parser_prompt_tokens": voice_session.parser_prompt_tokens,
"parser_completion_tokens": voice_session.parser_completion_tokens,
"parser_total_tokens": voice_session.parser_total_tokens,
},
"error": {
"code": voice_session.error_code,
"message": voice_session.error_message,
},
}
def get_voice_task_monitor_payload(workspace):
now = timezone.now()
window_start = now - timedelta(seconds=VOICE_TASK_MONITOR_WINDOW_SECONDS)
stale_threshold = get_voice_task_stale_threshold()
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
base_sessions = VoiceTaskSession.objects.filter(workspace=workspace)
window_sessions = base_sessions.filter(created_at__gte=window_start)
active_sessions = base_sessions.filter(
status__in=VOICE_TASK_ACTIVE_SESSION_STATUSES,
updated_at__gte=stale_threshold,
)
stale_sessions = base_sessions.filter(
status__in=VOICE_TASK_ACTIVE_SESSION_STATUSES,
updated_at__lt=stale_threshold,
)
status_counts = {
item["status"]: item["count"]
for item in window_sessions.values("status").annotate(count=Count("id"))
}
aggregate = window_sessions.aggregate(
total=Count("id"),
avg_processing_duration_ms=Avg("processing_duration_ms"),
avg_transcription_duration_ms=Avg("transcription_duration_ms"),
avg_parsing_duration_ms=Avg("parsing_duration_ms"),
total_audio_seconds=Sum("audio_duration_seconds"),
total_audio_size=Sum("audio_size"),
parser_total_tokens=Sum("parser_total_tokens"),
)
error_counts = list(
window_sessions.filter(status=VoiceTaskSession.Status.FAILED)
.exclude(error_code="")
.values("error_code")
.annotate(count=Count("id"))
.order_by("-count")[:8]
)
recent_sessions = (
base_sessions.select_related("user", "project", "created_task__project", "updated_task__project")
.order_by("-created_at")[:VOICE_TASK_MONITOR_RECENT_LIMIT]
)
active_session_rows = (
base_sessions.select_related("user", "project", "created_task__project", "updated_task__project")
.filter(status__in=VOICE_TASK_ACTIVE_SESSION_STATUSES)
.order_by("-created_at")[:VOICE_TASK_MONITOR_RECENT_LIMIT]
)
return {
"ok": True,
"generated_at": now,
"window_seconds": VOICE_TASK_MONITOR_WINDOW_SECONDS,
"stale_after_seconds": VOICE_TASK_ACTIVE_SESSION_STALE_SECONDS,
"concurrency": get_voice_task_concurrency_state(workspace, ai_settings),
"summary": {
"total": aggregate["total"] or 0,
"parsed": status_counts.get(VoiceTaskSession.Status.PARSED, 0),
"failed": status_counts.get(VoiceTaskSession.Status.FAILED, 0),
"active": active_sessions.count(),
"stale": stale_sessions.count(),
"avg_processing_duration_ms": aggregate["avg_processing_duration_ms"],
"avg_transcription_duration_ms": aggregate["avg_transcription_duration_ms"],
"avg_parsing_duration_ms": aggregate["avg_parsing_duration_ms"],
"total_audio_seconds": aggregate["total_audio_seconds"] or 0,
"total_audio_size": aggregate["total_audio_size"] or 0,
"parser_total_tokens": aggregate["parser_total_tokens"] or 0,
"status_counts": status_counts,
"error_counts": error_counts,
},
"active_sessions": [
serialize_voice_task_monitor_session(session, stale_threshold=stale_threshold)
for session in active_session_rows
],
"recent_sessions": [
serialize_voice_task_monitor_session(session, stale_threshold=stale_threshold)
for session in recent_sessions
],
}
class VoiceTaskMonitorEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
return Response(get_voice_task_monitor_payload(workspace), status=status.HTTP_200_OK)
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
action = request.data.get("action")
if action != "fail_stale":
return Response(
{"ok": False, "code": "unsupported_action", "error": "Unsupported Voice Task monitor action."},
status=status.HTTP_400_BAD_REQUEST,
)
now = timezone.now()
stale_sessions = list(
VoiceTaskSession.objects.filter(
workspace=workspace,
status__in=VOICE_TASK_ACTIVE_SESSION_STATUSES,
updated_at__lt=now - timedelta(seconds=VOICE_TASK_ACTIVE_SESSION_STALE_SECONDS),
)
)
for voice_session in stale_sessions:
voice_session.status = VoiceTaskSession.Status.FAILED
voice_session.failed_at = now
voice_session.processing_duration_ms = get_voice_task_duration_ms(
voice_session.processing_started_at,
now,
)
voice_session.error_code = "voice_task_stale_session"
voice_session.error_message = "Voice Tasker session was marked as stale by workspace admin."
voice_session.save(
update_fields=[
"status",
"failed_at",
"processing_duration_ms",
"error_code",
"error_message",
"updated_at",
]
)
try:
clear_voice_task_audio_file(voice_session)
except Exception as exc:
log_exception(exc)
payload = get_voice_task_monitor_payload(workspace)
payload["cleaned_count"] = len(stale_sessions)
return Response(payload, status=status.HTTP_200_OK)
class WorkspaceAISettingsEndpoint(BaseAPIView): class WorkspaceAISettingsEndpoint(BaseAPIView):
def get_settings(self, slug): def get_settings(self, slug):
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -3012,9 +3308,10 @@ class VoiceTaskParseEndpoint(BaseAPIView):
except Exception as exc: except Exception as exc:
log_exception(exc) log_exception(exc)
voice_session.status = VoiceTaskSession.Status.FAILED voice_session.status = VoiceTaskSession.Status.FAILED
voice_session.failed_at = timezone.now()
voice_session.error_code = "voice_task_queue_unavailable" voice_session.error_code = "voice_task_queue_unavailable"
voice_session.error_message = "Voice Tasker queue is not available." voice_session.error_message = "Voice Tasker queue is not available."
voice_session.save(update_fields=["status", "error_code", "error_message", "updated_at"]) voice_session.save(update_fields=["status", "failed_at", "error_code", "error_message", "updated_at"])
clear_voice_task_audio_file(voice_session) clear_voice_task_audio_file(voice_session)
return Response( return Response(
{ {

View File

@ -0,0 +1,68 @@
# Generated by Codex on 2026-04-28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0132_voice_tasker_queue_concurrency"),
]
operations = [
migrations.AddField(
model_name="voicetasksession",
name="completed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="failed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_completion_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_prompt_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parser_total_tokens",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="parsing_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="processing_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="processing_started_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="queued_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="transcribed_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name="voicetasksession",
name="transcription_duration_ms",
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View File

@ -128,6 +128,17 @@ class VoiceTaskSession(BaseModel):
intent = models.CharField(max_length=40, blank=True) intent = models.CharField(max_length=40, blank=True)
parsed_json = models.JSONField(blank=True, default=dict) parsed_json = models.JSONField(blank=True, default=dict)
client_context = models.JSONField(blank=True, default=dict) client_context = models.JSONField(blank=True, default=dict)
queued_at = models.DateTimeField(null=True, blank=True)
processing_started_at = models.DateTimeField(null=True, blank=True)
transcribed_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
failed_at = models.DateTimeField(null=True, blank=True)
transcription_duration_ms = models.PositiveIntegerField(null=True, blank=True)
parsing_duration_ms = models.PositiveIntegerField(null=True, blank=True)
processing_duration_ms = models.PositiveIntegerField(null=True, blank=True)
parser_prompt_tokens = models.PositiveIntegerField(null=True, blank=True)
parser_completion_tokens = models.PositiveIntegerField(null=True, blank=True)
parser_total_tokens = models.PositiveIntegerField(null=True, blank=True)
created_task = models.ForeignKey( created_task = models.ForeignKey(
"db.Issue", "db.Issue",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react";
import type { ElementType, ReactNode } from "react"; import type { ElementType, ReactNode } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { BrainCircuit, Check, FolderKanban, KeyRound, Mic, ShieldCheck, UsersRound } from "lucide-react"; import { Activity, AlertTriangle, BrainCircuit, Check, Clock3, FolderKanban, KeyRound, Mic, RotateCcw, ShieldCheck, UsersRound } from "lucide-react";
// plane imports // plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
@ -16,6 +16,8 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { import type {
IProject, IProject,
IWorkspaceMember, IWorkspaceMember,
TVoiceTaskMonitor,
TVoiceTaskMonitorSession,
TWorkspaceAIAccessMode, TWorkspaceAIAccessMode,
TWorkspaceAISettings, TWorkspaceAISettings,
TWorkspaceAISettingsPayload, TWorkspaceAISettingsPayload,
@ -113,6 +115,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
const [formState, setFormState] = useState<TFormState>(getInitialFormState()); const [formState, setFormState] = useState<TFormState>(getInitialFormState());
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const [isCleaningStaleSessions, setIsCleaningStaleSessions] = useState(false);
// store hooks // store hooks
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { fetchProjects, projectMap } = useProject(); const { fetchProjects, projectMap } = useProject();
@ -127,6 +130,15 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null, canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null
); );
const {
data: monitor,
isLoading: isMonitorLoading,
mutate: mutateMonitor,
} = useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_MONITOR_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveVoiceTaskMonitor(workspaceSlug) : null,
{ refreshInterval: 10000 }
);
useSWR( useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null, canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null,
@ -241,6 +253,25 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
} }
}; };
const handleCleanupStaleSessions = async () => {
setIsCleaningStaleSessions(true);
try {
const response = await workspaceAIService.cleanupStaleVoiceTaskSessions(workspaceSlug);
await mutateMonitor(response, false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: `Зависшие обработки сброшены: ${response.cleaned_count ?? 0}`,
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось сбросить зависшие обработки",
});
} finally {
setIsCleaningStaleSessions(false);
}
};
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) { if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />; return <NotAuthorizedView section="settings" className="h-auto" />;
} }
@ -425,6 +456,13 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
</div> </div>
</section> </section>
<VoiceTaskMonitorSection
isCleaning={isCleaningStaleSessions}
isLoading={isMonitorLoading}
monitor={monitor}
onCleanupStale={handleCleanupStaleSessions}
/>
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<Button <Button
variant="primary" variant="primary"
@ -609,6 +647,245 @@ function getWorkspaceRoleLabel(role: IWorkspaceMember["role"]) {
return "Участник"; return "Участник";
} }
type TVoiceTaskMonitorSectionProps = {
isCleaning: boolean;
isLoading: boolean;
monitor?: TVoiceTaskMonitor;
onCleanupStale: () => void;
};
function VoiceTaskMonitorSection({
isCleaning,
isLoading,
monitor,
onCleanupStale,
}: TVoiceTaskMonitorSectionProps) {
const staleCount = monitor?.summary.stale ?? 0;
const recentSessions = monitor?.recent_sessions ?? [];
const activeSessions = monitor?.active_sessions ?? [];
return (
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={Activity}
title="Очередь и диагностика"
description="Мониторинг обработок Voice Tasker за последние 24 часа: активные job, ошибки, latency и расход parser tokens."
right={
<Button
variant="secondary"
size="md"
className="nodedc-settings-chip min-w-[11rem]"
disabled={!monitor || staleCount === 0}
loading={isCleaning}
onClick={onCleanupStale}
>
<RotateCcw className="size-3.5" />
Сбросить зависшие
</Button>
}
/>
{isLoading || !monitor ? (
<div className="px-5 pb-5 text-12 text-tertiary">Загрузка мониторинга...</div>
) : (
<div className="flex flex-col gap-5 border-t border-white/5 px-5 py-5">
<div className="grid gap-3 md:grid-cols-4">
<MonitorMetricCard
label="Активные"
value={`${monitor.summary.active}/${monitor.concurrency.limit}`}
meta={staleCount ? `зависшие: ${staleCount}` : "очередь в норме"}
tone={staleCount ? "danger" : "accent"}
/>
<MonitorMetricCard
label="Успешно"
value={formatMonitorNumber(monitor.summary.parsed)}
meta={`всего: ${formatMonitorNumber(monitor.summary.total)}`}
/>
<MonitorMetricCard
label="Ошибки"
value={formatMonitorNumber(monitor.summary.failed)}
meta={monitor.summary.error_counts[0]?.error_code || "без ошибок"}
tone={monitor.summary.failed ? "danger" : "default"}
/>
<MonitorMetricCard
label="Средняя обработка"
value={formatDurationMs(monitor.summary.avg_processing_duration_ms)}
meta={`${formatAudioMinutes(monitor.summary.total_audio_seconds)} аудио`}
/>
<MonitorMetricCard
label="Transcribe"
value={formatDurationMs(monitor.summary.avg_transcription_duration_ms)}
meta="средняя стадия"
/>
<MonitorMetricCard
label="Parse"
value={formatDurationMs(monitor.summary.avg_parsing_duration_ms)}
meta="средняя стадия"
/>
<MonitorMetricCard
label="Parser tokens"
value={formatMonitorNumber(monitor.summary.parser_total_tokens)}
meta="OpenAI usage proxy"
/>
<MonitorMetricCard
label="Audio size"
value={formatBytes(monitor.summary.total_audio_size)}
meta="загружено за сутки"
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<MonitorSessionList title="Активные обработки" sessions={activeSessions} emptyText="Активных обработок нет." />
<MonitorSessionList title="Последние сессии" sessions={recentSessions} emptyText="Сессий пока нет." />
</div>
</div>
)}
</section>
);
}
type TMonitorMetricCardProps = {
label: string;
meta: string;
tone?: "accent" | "danger" | "default";
value: string;
};
function MonitorMetricCard({ label, meta, tone = "default", value }: TMonitorMetricCardProps) {
return (
<div className="rounded-[1.35rem] bg-white/[0.045] px-4 py-3">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">{label}</div>
<div
className={cn(
"mt-2 text-2xl font-semibold",
tone === "accent" && "text-[rgb(var(--nodedc-accent-rgb))]",
tone === "danger" && "text-red-300",
tone === "default" && "text-primary"
)}
>
{value}
</div>
<div className="mt-1 truncate text-11 text-tertiary">{meta}</div>
</div>
);
}
type TMonitorSessionListProps = {
emptyText: string;
sessions: TVoiceTaskMonitorSession[];
title: string;
};
function MonitorSessionList({ emptyText, sessions, title }: TMonitorSessionListProps) {
return (
<div className="rounded-[1.35rem] bg-white/[0.035] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-12 font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</span>
<span className="nodedc-settings-chip px-3 py-1 text-11 text-secondary">{sessions.length}</span>
</div>
{sessions.length === 0 ? (
<div className="text-12 text-tertiary">{emptyText}</div>
) : (
<div className="flex max-h-80 flex-col gap-2 overflow-auto pr-1">
{sessions.map((session) => (
<MonitorSessionRow key={session.id} session={session} />
))}
</div>
)}
</div>
);
}
function MonitorSessionRow({ session }: { session: TVoiceTaskMonitorSession }) {
const title = session.issue.key || session.project.identifier || session.intent || "Voice Tasker";
const subtitle = [session.user.name, session.project.name].filter(Boolean).join(" / ");
return (
<div className="rounded-2xl bg-white/[0.045] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-13 font-semibold text-primary">{title}</div>
<div className="mt-1 truncate text-11 text-tertiary">{subtitle || "Без проекта"}</div>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-11",
session.status === "failed"
? "bg-red-500/12 text-red-300"
: session.is_active
? "bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]"
: "bg-white/7 text-secondary"
)}
>
{session.is_stale && <AlertTriangle className="size-3" />}
{getVoiceTaskStatusLabel(session.status)}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-11 text-tertiary">
<span className="nodedc-settings-chip px-2.5 py-1">
<Clock3 className="size-3" />
{formatDurationMs(session.timings.processing_duration_ms)}
</span>
<span className="nodedc-settings-chip px-2.5 py-1">{formatBytes(session.audio.size)}</span>
<span className="nodedc-settings-chip px-2.5 py-1">
{formatDateTime(session.timings.completed_at || session.timings.failed_at || session.timings.created_at)}
</span>
</div>
{session.error.code && <div className="mt-2 truncate text-11 text-red-300">{session.error.code}</div>}
</div>
);
}
function getVoiceTaskStatusLabel(status: TVoiceTaskMonitorSession["status"]) {
const labels: Record<TVoiceTaskMonitorSession["status"], string> = {
queued: "В очереди",
processing: "Обработка",
uploaded: "Загружено",
transcribing: "Транскрибация",
transcribed: "Текст готов",
parsing: "Формирование",
parsed: "Готово",
failed: "Ошибка",
};
return labels[status] || status;
}
function formatMonitorNumber(value: number | null | undefined) {
return new Intl.NumberFormat("ru-RU").format(value ?? 0);
}
function formatDurationMs(value: number | null | undefined) {
if (!value) return "0 сек";
if (value < 1000) return `${Math.round(value)} мс`;
return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} сек`;
}
function formatAudioMinutes(value: number | null | undefined) {
const seconds = value ?? 0;
if (seconds < 60) return `${Math.round(seconds)} сек`;
return `${(seconds / 60).toFixed(1)} мин`;
}
function formatBytes(value: number | null | undefined) {
const bytes = value ?? 0;
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
function formatDateTime(value: string | null | undefined) {
if (!value) return "нет даты";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "нет даты";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "short",
}).format(date);
}
function Field({ children, label }: TFieldProps) { function Field({ children, label }: TFieldProps) {
return ( return (
<label className="flex flex-col gap-2.5"> <label className="flex flex-col gap-2.5">

View File

@ -9,6 +9,7 @@ import type {
TVoiceTaskCommitResult, TVoiceTaskCommitResult,
TVoiceTaskPreflight, TVoiceTaskPreflight,
TVoiceTaskDraft, TVoiceTaskDraft,
TVoiceTaskMonitor,
TVoiceTaskUploadResult, TVoiceTaskUploadResult,
TWorkspaceAIConnectionTestResult, TWorkspaceAIConnectionTestResult,
TWorkspaceAISettings, TWorkspaceAISettings,
@ -45,6 +46,22 @@ export class WorkspaceAIService extends APIService {
}); });
} }
async retrieveVoiceTaskMonitor(workspaceSlug: string): Promise<TVoiceTaskMonitor> {
return this.get(`/api/workspaces/${workspaceSlug}/voice-tasker/monitor/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async cleanupStaleVoiceTaskSessions(workspaceSlug: string): Promise<TVoiceTaskMonitor> {
return this.post(`/api/workspaces/${workspaceSlug}/voice-tasker/monitor/`, { action: "fail_stale" })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveVoiceTaskPreflight(workspaceSlug: string, projectId?: string | null): Promise<TVoiceTaskPreflight> { async retrieveVoiceTaskPreflight(workspaceSlug: string, projectId?: string | null): Promise<TVoiceTaskPreflight> {
const params = projectId ? `?project_id=${projectId}` : ""; const params = projectId ? `?project_id=${projectId}` : "";
return this.get( return this.get(

View File

@ -214,6 +214,91 @@ export type TVoiceTaskUploadResult = {
error?: string; error?: string;
}; };
export type TVoiceTaskMonitorSession = {
id: string;
status: "queued" | "processing" | "uploaded" | "transcribing" | "transcribed" | "parsing" | "parsed" | "failed";
intent: TVoiceTaskIntent | "";
is_active: boolean;
is_stale: boolean;
user: {
id: string | null;
name: string;
email: string;
};
project: {
id: string | null;
name: string;
identifier: string;
};
issue: {
id: string | null;
key: string;
name: string;
};
audio: {
duration_seconds: number | null;
size: number | null;
content_type: string;
has_temp_file: boolean;
};
timings: {
queued_at: string | null;
processing_started_at: string | null;
transcribed_at: string | null;
completed_at: string | null;
failed_at: string | null;
transcription_duration_ms: number | null;
parsing_duration_ms: number | null;
processing_duration_ms: number | null;
last_update_at: string;
created_at: string;
};
usage: {
parser_prompt_tokens: number | null;
parser_completion_tokens: number | null;
parser_total_tokens: number | null;
};
error: {
code: string;
message: string;
};
};
export type TVoiceTaskMonitor = {
ok: boolean;
generated_at: string;
window_seconds: number;
stale_after_seconds: number;
cleaned_count?: number;
concurrency: {
scope: "workspace";
limit: number;
used: number;
exceeded: boolean;
retry_after: number;
};
summary: {
total: number;
parsed: number;
failed: number;
active: number;
stale: number;
avg_processing_duration_ms: number | null;
avg_transcription_duration_ms: number | null;
avg_parsing_duration_ms: number | null;
total_audio_seconds: number;
total_audio_size: number;
parser_total_tokens: number;
status_counts: Record<string, number>;
error_counts: {
error_code: string;
count: number;
}[];
};
active_sessions: TVoiceTaskMonitorSession[];
recent_sessions: TVoiceTaskMonitorSession[];
};
export type TVoiceTaskCommitResult = { export type TVoiceTaskCommitResult = {
ok: boolean; ok: boolean;
status?: "created" | "updated" | "deleted"; status?: "created" | "updated" | "deleted";