From ee8b5123d81483326584113b01d3c353cfdbbeb7 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 26 Apr 2026 14:35:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=20=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D1=85=20Voice=20Tasker=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apps/api/plane/app/views/voice_tasker.py | 214 +++++++++++++++++- .../voice-tasker/global-control.tsx | 72 +++--- plane-src/packages/types/src/ai.ts | 17 +- 3 files changed, 258 insertions(+), 45 deletions(-) diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index ab8d05e..2e02280 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -186,6 +186,39 @@ VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile( VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile( r"(?[0-3]?\d)[./-](?P[01]?\d)(?:[./-](?P\d{2,4}))?(?!\d)" ) +VOICE_TASK_WEEKDAYS = { + "понедельник": 0, + "понедельника": 0, + "понедельнику": 0, + "monday": 0, + "вторник": 1, + "вторника": 1, + "вторнику": 1, + "tuesday": 1, + "среда": 2, + "среду": 2, + "среды": 2, + "wednesday": 2, + "четверг": 3, + "четверга": 3, + "четвергу": 3, + "thursday": 3, + "пятница": 4, + "пятницу": 4, + "пятницы": 4, + "friday": 4, + "суббота": 5, + "субботу": 5, + "субботы": 5, + "saturday": 5, + "воскресенье": 6, + "воскресенья": 6, + "воскресенью": 6, + "sunday": 6, +} +VOICE_TASK_WEEKDAY_PATTERN = re.compile( + rf"(?{'|'.join(sorted(VOICE_TASK_WEEKDAYS.keys(), key=len, reverse=True))})(?![0-9a-zа-я])" +) def normalize_audio_content_type(content_type): @@ -358,6 +391,11 @@ class VoiceTaskParserService: "For create_task, title must be a compact but meaning-preserving task name, not a 2-word summary. " "description should be a detailed structured summary that preserves the user's meaning; " "checklist should contain actionable bullet decomposition when the transcript includes multiple steps. " + "Return title, description, labels, checklist, and questions in the same natural language as " + "the transcript. If the transcript is Russian, all human-facing text must be Russian; never " + "translate Russian task text into English. " + "If the user says to assign the task to all employees/team members of a project/department, " + "set assignee_hint to all_project_members. " "Use state_hint only for explicit status/state phrases like в работе, в реализации, active, backlog, done. " "Do not infer state_hint from project names. If no status is requested, return null. " "Never classify delete/remove/cancel-last-task commands as create_task. " @@ -570,7 +608,87 @@ def derive_voice_task_title_from_text(value): return title or None +def voice_task_text_has_cyrillic(value): + return bool(normalize_string(value) and re.search(r"[А-Яа-яЁё]", value)) + + +def voice_task_text_looks_latin(value): + normalized = normalize_string(value) + if not normalized: + return False + + latin_count = len(re.findall(r"[A-Za-z]", normalized)) + cyrillic_count = len(re.findall(r"[А-Яа-яЁё]", normalized)) + return latin_count >= 12 and latin_count > cyrillic_count * 2 + + +def derive_voice_task_title_from_transcript(transcript): + normalized = normalize_string(transcript, 1200) + if not normalized: + return None + + action_verbs = [ + "отправить", + "сдать", + "подготовить", + "закрыть", + "согласовать", + "проверить", + "добавить", + "создать", + "передать", + "позвонить", + "написать", + "сделать", + "разобрать", + "обновить", + "исправить", + "сверить", + "выгрузить", + "загрузить", + "оформить", + ] + sentences = [sentence.strip(" :-,;") for sentence in re.split(r"[.!?\n]+", normalized) if sentence.strip()] + for sentence in sentences: + sentence_lower = sentence.lower().replace("ё", "е") + if "задач" not in sentence_lower: + continue + best_index = None + for verb in action_verbs: + match = re.search(rf"(?= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: + if assignees: + assignee_ids = [ + assignee["id"] + for assignee in assignees + if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD + ] + elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: assignee_ids = [assignee["id"]] return { @@ -1636,6 +1827,7 @@ def build_voice_task_issue_payload(draft, resolution, transcript=None): def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=None): assignee = resolution.get("assignee") + assignees = resolution.get("assignees") or [] state = resolution.get("state") labels = resolution.get("labels") or [] project_change = resolution.get("project_change") @@ -1654,7 +1846,13 @@ def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=N if draft.get("priority") and draft["priority"] != "none": payload["priority"] = draft["priority"] - if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: + if assignees: + payload["assignee_ids"] = [ + assignee["id"] + for assignee in assignees + if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD + ] + elif assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: payload["assignee_ids"] = [assignee["id"]] if (draft.get("state_hint") or project_change) and state and state["confidence"] >= VOICE_TASK_STATE_MATCH_THRESHOLD: diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index 33cde1c..2cba0d1 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -98,6 +98,23 @@ function getVoiceTaskWarnings(result: TVoiceTaskUploadResult) { ); } +type TVoiceTaskResolutionWithAssignees = NonNullable & { + assignees?: NonNullable["assignee"][]; +}; + +function getVoiceTaskAssigneesLabel(result: TVoiceTaskUploadResult) { + const resolution = result.resolution as TVoiceTaskResolutionWithAssignees | undefined; + const resolvedAssignees = resolution?.assignees?.length + ? resolution.assignees + : resolution?.assignee + ? [resolution.assignee] + : []; + + if (resolvedAssignees.length > 0) + return resolvedAssignees.flatMap((assignee) => (assignee ? [assignee.name] : [])).join(", "); + return result.draft?.assignee_hint || "не распознано"; +} + type Props = { workspaceSlug: string; }; @@ -310,34 +327,31 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { } }; - const refreshVisibleIssueStores = useCallback( - async () => { - const refreshes: Promise[] = []; + const refreshVisibleIssueStores = useCallback(async () => { + const refreshes: Promise[] = []; - if (activeProjectId) { - refreshes.push(refreshProjectIssues(workspaceSlug, activeProjectId, "mutation")); - if (activeProjectViewId) { - refreshes.push(refreshProjectViewIssues(workspaceSlug, activeProjectId, activeProjectViewId, "mutation")); - } + if (activeProjectId) { + refreshes.push(refreshProjectIssues(workspaceSlug, activeProjectId, "mutation")); + if (activeProjectViewId) { + refreshes.push(refreshProjectViewIssues(workspaceSlug, activeProjectId, activeProjectViewId, "mutation")); } + } - if (activeGlobalViewId) { - refreshes.push(refreshGlobalIssues(workspaceSlug, activeGlobalViewId, "mutation")); - } + if (activeGlobalViewId) { + refreshes.push(refreshGlobalIssues(workspaceSlug, activeGlobalViewId, "mutation")); + } - if (!refreshes.length) return; - await Promise.allSettled(refreshes); - }, - [ - activeGlobalViewId, - activeProjectId, - activeProjectViewId, - refreshGlobalIssues, - refreshProjectIssues, - refreshProjectViewIssues, - workspaceSlug, - ] - ); + if (!refreshes.length) return; + await Promise.allSettled(refreshes); + }, [ + activeGlobalViewId, + activeProjectId, + activeProjectViewId, + refreshGlobalIssues, + refreshProjectIssues, + refreshProjectViewIssues, + workspaceSlug, + ]); const commitVoiceTask = async () => { if (!parseResult?.voice_session_id || !parseResult.draft) return; @@ -389,7 +403,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {