ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: мониторинг очереди Voice Tasker
This commit is contained in:
parent
3b295e33f3
commit
d60c28ec04
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue