diff --git a/docs_prod/2_voicetasker/VOICETASKER_TECH.md b/docs_prod/2_voicetasker/VOICETASKER_TECH.md index b335505..24eaaea 100644 --- a/docs_prod/2_voicetasker/VOICETASKER_TECH.md +++ b/docs_prod/2_voicetasker/VOICETASKER_TECH.md @@ -52,11 +52,12 @@ Voice Tasker не создает отдельную модель задачи. 1. workspace AI settings; 2. encrypted workspace credentials; -3. voice sessions; -4. voice memory. +3. voice sessions. Не создавать отдельные `VoiceTask`, `VoiceProject`, `VoiceUser`, `VoiceInbox` как обязательные бизнес-сущности. +Voice memory в MVP не является отдельной бизнес-сущностью и не требует отдельной таблицы. Последние voice-действия восстанавливаются из `voice_task_sessions.created_task_id`, `voice_task_sessions.updated_task_id`, `parsed_json`, `created_at`, `updated_at`. Отдельная `voice_task_memory` допустима только как будущая оптимизация, если session-backed memory перестанет закрывать продуктовый сценарий. + ### 2.2. Backend-only OpenAI Frontend: @@ -75,7 +76,7 @@ Backend: - валидирует JSON; - резолвит project/member; - создает/редактирует work item через внутренние Plane модели/serializer/service; -- пишет voice session и memory. +- пишет voice session и session-backed memory. ### 2.3. Не использовать внешний Plane REST API @@ -148,10 +149,27 @@ Native поля "deadline time" у work item сейчас нет. 2. фраза "до 15:00" сохраняется в draft как `due_time`; 3. при commit `due_time` не создает новое поле в `Issue`; 4. если время важно, оно добавляется в `description_html` отдельной строкой, например: `Ориентир по времени: до 15:00`; -5. в `voice_task_sessions.parsed_json` время сохраняется как структурированное значение для будущей миграции. +5. относительные сроки вида "на две недели вперед", "на месяц назад", "через два месяца и две недели", "на год перенеси" нормализуются backend rule-based слоем относительно даты пользователя/workspace; +6. если задача уже имеет срок и команда звучит как перенос/сдвиг, допустимо считать от текущего `Issue.target_date`; +7. в `voice_task_sessions.parsed_json` время сохраняется как структурированное значение для будущей миграции. Отдельное native поле дедлайна со временем (`target_datetime`, `due_at` или аналог) не входит в MVP и выносится в backlog как архитектурное расширение. +### 3.5. Статус задачи + +В текущей модели Plane статус work item хранится через `Issue.state_id` и проектные `State`. + +MVP-правило: + +1. если пользователь явно говорит "в работе", "в реализации", "в реализацию", "активный статус", "в активном статусе" - resolver выбирает state из group `started`; +2. если пользователь явно называет статус проекта - resolver делает exact/fuzzy match по имени state; +3. если модель не вернула `state_hint`, backend делает ограниченный fallback по явной status-фразе из transcript; +4. если статус не назван, backend не должен отдавать выбор случайному default/последнему статусу Plane; +5. default для новой voice-задачи: первый `unstarted` state проекта, затем первый `started`, затем backlog только как последний fallback; +6. `completed`/`cancelled` допустимы только при явном status/state hint пользователя. + +Цель: voice-create не должен создавать задачи в закрытых/отложенных статусах и не должен попадать в backlog, если в проекте есть нормальный open-state. + --- ## 4. Пользовательские сценарии @@ -170,6 +188,7 @@ Parser возвращает draft: { "intent": "create_task", "project_hint": "контур бухгалтерии", + "state_hint": null, "assignee_hint": "Настя / бухгалтер Настя", "title": "Подготовить декларацию по НДС", "description": "Необходимо подготовить декларацию по НДС.", @@ -202,10 +221,13 @@ Commit маппит draft в Plane: MVP-правило: -1. если `project_confidence >= 0.8`, можно auto-create; -2. если проект не найден уверенно, показываем preview с ручным выбором project; -3. если admin заранее указал `default_project_id`, можно предложить его как fallback; -4. если fallback project не задан, задачу не создаем автоматически. +1. backend сначала ищет проект в самом transcript по именам, identifiers и утвержденным алиасам; +2. transcript-резолв имеет приоритет над `project_hint`, потому что модель может галлюцинировать текущий проект; +3. если `project_hint` найден уверенно и не конфликтует с transcript, можно auto-create; +4. если пользователь явно назвал контур/проект, но resolver не нашел его уверенно, запрещено тихо fallback-иться в current/default project; +5. если проект не найден уверенно, показываем preview с ручным выбором project; +6. если admin заранее указал `default_project_id`, можно использовать его только когда пользователь не называл другой проект явно; +7. если fallback project не задан, задачу не создаем автоматически. Не создавать "общую помойку" автоматически. @@ -235,7 +257,16 @@ MVP-правило: 3. показывает preview изменения, если confidence низкий; 4. меняет `Issue.target_date`; 5. сохраняет `due_time` в description note / parsed JSON; -6. пишет новое действие в voice memory. +6. пишет новое действие в session-backed memory. + +Если пользователь говорит "переложи последнюю задачу в проект X", это остается `update_task`, но backend должен: + +1. найти target issue через session-backed memory; +2. отдельно зарезолвить целевой project из transcript/project hint; +3. показать в preview `project_change.from -> project_change.to`; +4. при commit перенести обычную `Issue` в целевой `Project`, выдать ей новый `sequence_id` целевого проекта и валидный `State`; +5. для фраз "из Бухгалтерии в Менеджмент" считать project после destination-маркера "в/во/на/to" целевым, а project после "из/from" исходным контекстом; +6. не считать update успешным, если целевой project не найден или у пользователя нет прав создать/редактировать задачу в целевом проекте. ### 4.5. Удаление последней voice-задачи @@ -249,7 +280,7 @@ MVP-правило: - удаление всегда требует confirmation modal; - backend повторно проверяет права на удаление; -- действие пишется в voice memory; +- действие пишется в session-backed memory; - предпочтительно использовать тот же delete path, что обычный work item, чтобы сохранился activity log. --- @@ -294,8 +325,8 @@ recording - идет запись uploading - отправка аудио processing - транскрибация и разбор success - draft разобран -committing - создание задачи -committed - задача создана / обновлена +committing - применение voice-действия +committed - задача создана / обновлена / удалена error - ошибка ``` @@ -316,7 +347,7 @@ error - ошибка Кнопки: -- `Создать задачу` / `Применить изменения`; +- `Создать задачу` / `Применить изменения` / `Удалить задачу`; - `Редактировать`; - `Отмена`. @@ -397,7 +428,6 @@ Backend: GET /api/workspaces/:workspaceSlug/voice-task/preflight POST /api/workspaces/:workspaceSlug/voice-task/parse POST /api/workspaces/:workspaceSlug/voice-task/commit -POST /api/workspaces/:workspaceSlug/voice-task/resolve-command ``` ### 7.1. Preflight @@ -473,6 +503,7 @@ Response: "intent": "create_task", "target_memory_ref": null, "project_hint": "контур бухгалтерии", + "state_hint": null, "assignee_hint": "Настя", "title": "Подготовить декларацию по НДС", "description": "Необходимо подготовить декларацию по НДС.", @@ -497,6 +528,13 @@ Response: "confidence": 0.91, "source": "project_hint" }, + "state": { + "id": "state_uuid", + "name": "К выполнению", + "group": "unstarted", + "confidence": 0.65, + "source": "default_open_state" + }, "assignee": { "id": "user_uuid", "name": "Настя", @@ -510,6 +548,7 @@ Response: "name": "voice" } ], + "target_task": null, "warnings": [], "can_commit": true }, @@ -536,11 +575,12 @@ Payload: ```json { "voice_session_id": "uuid", - "action": "create_task", + "action": "create_task | update_task | delete_task", "draft": { "title": "Подготовить декларацию по НДС", "description": "Необходимо подготовить декларацию по НДС.", "project_id": "project_uuid", + "state_hint": null, "assignee_ids": ["user_uuid"], "due_date": "2026-04-24", "due_time": "15:00", @@ -558,6 +598,7 @@ Internal Plane payload: "description_html": "
Необходимо подготовить декларацию по НДС.
Ориентир по времени: до 15:00
", "target_date": "2026-04-24", "priority": "high", + "state_id": "state_uuid", "assignees": ["user_uuid"], "labels": ["label_uuid"] } @@ -583,6 +624,13 @@ Response: "confidence": 0.91, "source": "project_hint" }, + "state": { + "id": "state_uuid", + "name": "К выполнению", + "group": "unstarted", + "confidence": 0.65, + "source": "default_open_state" + }, "assignee": { "id": "user_uuid", "name": "Настя", @@ -591,13 +639,20 @@ Response: "source": "assignee_hint" }, "labels": [{ "id": "label_uuid", "name": "voice" }], + "target_task": null, "warnings": [], "can_commit": true } } ``` -На Stage 4 commit поддерживает только `create_task`. `update_task` и `delete_task` остаются в следующих этапах, потому что требуют voice memory и отдельного confirmation policy. +Commit поддерживает: + +- `create_task` - создает обычную `Issue`; +- `update_task` - находит target issue через session-backed memory и применяет частичный update; +- `delete_task` - находит target issue через session-backed memory и удаляет через тот же delete path, что обычный work item. + +Для `update_task`/`delete_task` response содержит `resolution.target_task` с ключом, названием, проектом и источником резолва. --- @@ -675,28 +730,32 @@ Audio file в MVP не хранить после обработки. Transcript и parsed JSON хранить для поддержки preview, отладки и memory. Retention policy нужно вынести в отдельную настройку после MVP. -### 8.4. `voice_task_memory` +### 8.4. Session-backed voice memory -Поля: +В MVP отдельная таблица `voice_task_memory` не создается. + +Источник memory: ```txt -id -workspace_id -user_id -task_id -voice_session_id -action -summary -created_at +voice_task_sessions.workspace_id +voice_task_sessions.user_id +voice_task_sessions.intent +voice_task_sessions.parsed_json +voice_task_sessions.created_task_id +voice_task_sessions.updated_task_id +voice_task_sessions.created_at +voice_task_sessions.updated_at ``` Использование: - хранить последние N voice-действий пользователя; -- N по умолчанию: 10; +- N по умолчанию для parser context: 5; - резолвить "последняя задача", "предыдущая задача", "та задача в бухгалтерии"; - не использовать memory как источник истины вместо `Issue`. +Если в будущем понадобится отдельная агрегированная history/memory-таблица, она должна быть добавлена отдельным архитектурным этапом и не должна дублировать `Issue` как источник истины. + --- ## 9. OpenAI pipeline @@ -741,7 +800,26 @@ Input: "transcript": "...", "workspace_projects": [], "workspace_members": [], - "recent_voice_memory": [], + "recent_voice_memory": [ + { + "voice_session_id": "uuid", + "intent": "create_task", + "title": "Подготовить декларацию по НДС", + "project_hint": "Бухгалтерия", + "target_task": { + "id": "issue_uuid", + "title": "Подготовить декларацию по НДС", + "key": "BUH-128", + "project_id": "project_uuid", + "project_name": "Бухгалтерия", + "project_identifier": "BUH", + "sequence_id": 128, + "source": "recent_voice_memory", + "voice_session_id": "uuid" + }, + "created_at": "2026-04-24T10:00:00+03:00" + } + ], "current_date": "2026-04-24", "timezone": "Europe/Moscow" } @@ -752,7 +830,7 @@ Output строго JSON: ```json { "intent": "create_task | update_task | delete_task | unknown", - "target_memory_ref": "last_task | previous_task | explicit_task | null", + "target_memory_ref": "voice_session_id | issue_key | issue_id | null", "project_hint": "string | null", "assignee_hint": "string | null", "title": "string | null", @@ -789,20 +867,37 @@ Return JSON only. Вход: - `project_hint`; +- transcript; - список проектов workspace, доступных пользователю; - `current_project_id`, если пользователь находится внутри проекта; - `default_project_id` из settings. Логика: -1. exact match по имени/identifier; -2. fuzzy match по имени; -3. current project как слабый fallback; -4. default project как fallback, если задан; -5. если confidence низкий - preview с ручным выбором. +1. exact/alias match по transcript: имя проекта, identifier, короткие алиасы (`MGR`, `BUH`, `CODEX`) и согласованные русские UX-формы вроде "контур менеджмент"; +2. transcript match имеет приоритет над `project_hint`; +3. exact/fuzzy match по `project_hint` и candidates проекта; +4. current project как слабый fallback только если пользователь не назвал проект/контур явно; +5. default project как fallback только если пользователь не назвал проект/контур явно; +6. если confidence низкий - preview с ручным выбором; +7. если transcript содержит явную маршрутизацию проекта, но model `project_hint` указывает на проект, которого нет в transcript, auto-commit блокируется; +8. при наличии source/destination фразы выбирать destination project: "из Бухгалтерии в Менеджмент" -> target `Менеджмент`, "из Менеджмента в Бухгалтерию" -> target `Бухгалтерия`. Не зашивать термин "контур" как обязательный. Для NODE DC это важный UX-термин, но технически это обычный `Project`. +### 10.1.1. Перенос задачи между проектами + +Команда вида "переложи последнюю задачу в проект Менеджмент" не создает новую задачу. + +Правило: + +1. target issue находится через session-backed memory; +2. целевой project резолвится тем же project resolver, но без current/default fallback; +3. если целевой project отличается от исходного, response содержит `resolution.project_change`; +4. commit переносит `Issue.project_id`, выдает новый `sequence_id` целевого проекта через `IssueSequence`, выбирает state целевого проекта по явному `state_hint`, group исходного state или default open-state; +5. assignees/labels сохраняются только если они валидны в целевом project; +6. при неуверенном project resolver или недостатке прав перенос не выполняется. + ### 10.2. Assignee resolver Вход: @@ -820,15 +915,60 @@ Return JSON only. Назначать можно только пользователей, которые состоят в project и имеют достаточную роль. -### 10.3. Date resolver +### 10.3. State resolver + +Вход: + +- `state_hint`; +- states выбранного project; +- transcript как fallback, если модель не вернула `state_hint`; +- group state: `backlog`, `unstarted`, `started`, `completed`, `cancelled`. + +Логика: + +1. exact/fuzzy match по имени state; +2. словари синонимов для group: + - "в работе", "в реализации", "в реализацию", "активный", "активном статусе" -> `started`; + - "к выполнению", "todo", "новая" -> `unstarted`; + - "бэклог", "backlog" -> `backlog`; + - "готово", "закрыто" -> `completed`; +3. если `state_hint` отсутствует, но transcript содержит явную status-фразу, использовать ее как backend fallback; +4. если `state_hint` отсутствует при создании - выбрать default open-state: сначала `unstarted`, затем `started`, затем backlog; +5. не выбирать `completed`/`cancelled` без явного state hint пользователя. + +### 10.4. Date resolver MVP: - сегодня; - завтра; +- послезавтра / вчера / позавчера; +- `N` дней/недель/месяцев/лет вперед; +- `N` дней/недель/месяцев/лет назад; +- сложные интервалы: "два месяца и две недели"; +- числительные цифрами и частые русские словоформы: "2 недели", "две недели", "пару дней"; +- защита от ложных матчей внутри слов: "последней" не считается как "дней"; - конкретная дата; - конкретное время как `due_time` note. +Date resolver обязан работать после OpenAI parser как deterministic fallback. Если модель уже вернула валидный `due_date`, backend его не переписывает. + +### 10.5. Voice task representation in Issue + +Voice-created/voice-updated work item остается обычной `Issue`, но получает техническую маркировку: + +- `Issue.external_source = voice_tasker`; +- `Issue.external_id = voice_session_id`. + +Описание voice-created задачи формируется многоуровнево: + +1. источник `Voice Tasker`; +2. подробная постановка из parser `description`; +3. декомпозиция из `checklist`, если она есть; +4. исходная транскрибация пользователя отдельным блоком `Исходная транскрибация пользователя`. + +Kanban/list UI может использовать `external_source=voice_tasker` для отличимого отображения voice-задач без создания отдельной бизнес-сущности. + Backlog: - к пятнице; @@ -927,7 +1067,7 @@ MVP: - хранить transcript N дней; - очищать transcript после commit; - хранить только parsed JSON; -- отключать voice memory для sensitive workspace. +- отключать session-backed voice memory для sensitive workspace. --- @@ -991,6 +1131,7 @@ voice_task.error ### Stage 4 - Preview и создание задачи - project resolver; +- state resolver и безопасный default open-state; - assignee resolver; - date resolver MVP; - preview modal; @@ -998,14 +1139,19 @@ voice_task.error - создание `Issue` через внутренний Plane layer; - activity log/model activity как у обычного work item; - точечное обновление активного issue-store после commit без reload/polling; -- `voice_task_memory` для created action. +- session-backed memory для created action. ### Stage 5 - Memory commands - update last task; - delete last task with confirmation; - append description/checklist to last task; -- memory resolver для "последняя/предыдущая/та задача". +- memory resolver для "последняя/предыдущая/та задача"; +- transcript-first project routing и базовые project aliases; +- перенос последней voice-задачи между проектами через обычную `Issue`. +- относительные сроки русским естественным языком; +- маркировка voice-задач через `Issue.external_source`; +- сохранение полного transcript в description_html созданной/обновленной задачи. --- @@ -1017,7 +1163,7 @@ voice_task.error - Voice Inbox как отдельный управляемый fallback project/triage flow; - Redis/Celery-backed VoiceTaskQueue; - transcript retention policies; -- project/member aliases; +- расширяемые project/member aliases в настройках workspace; - выбранные роли beyond `all members/admins only`; - monthly budget/soft cap; - multi-provider AI; @@ -1039,18 +1185,22 @@ voice_task.error 7. Backend транскрибирует через OpenAI. 8. Backend формирует валидный structured draft. 9. Project resolver выбирает project или требует ручной выбор. -10. Assignee resolver назначает только уверенно найденного project member. -11. Если assignee не найден - задача может быть создана без assignee. -12. Commit создает обычную `Issue` через внутренний Plane backend layer. -13. После commit активная доска/список обновляется без reload страницы. -14. `due_date` маппится в `target_date`. -15. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. -15. Voice session сохраняется. -16. Последняя voice-задача сохраняется в memory. -17. Update last task работает минимум для `target_date` и description. -18. Delete last task требует confirmation. -19. User/workspace limits работают. -20. OpenAI errors показываются пользователю как нормальные сообщения без raw stack trace. +10. State resolver выбирает явный state или безопасный default open-state. +11. Assignee resolver назначает только уверенно найденного project member. +12. Если assignee не найден - задача может быть создана без assignee. +13. Commit создает обычную `Issue` через внутренний Plane backend layer. +14. После commit активная доска/список обновляется без reload страницы. +15. `due_date` маппится в `target_date`. +16. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. +17. Voice session сохраняется. +18. Последняя voice-задача сохраняется в session-backed memory. +19. Update last task работает минимум для `target_date`, state и description. +20. Delete last task требует confirmation. +21. User/workspace limits работают. +22. OpenAI errors показываются пользователю как нормальные сообщения без raw stack trace. +23. Относительная дата "на две недели вперед" меняет `Issue.target_date` без ручного ввода ISO-даты. +24. Перенос "из Бухгалтерии в Менеджмент" и "из Менеджмента в Бухгалтерию" реально меняет `Issue.project_id` и `task_key`. +25. Voice-created task имеет `external_source=voice_tasker` и хранит исходный transcript в description. --- diff --git a/plane-src/apps/api/plane/app/serializers/issue.py b/plane-src/apps/api/plane/app/serializers/issue.py index 5efae03..ea187f1 100644 --- a/plane-src/apps/api/plane/app/serializers/issue.py +++ b/plane-src/apps/api/plane/app/serializers/issue.py @@ -799,6 +799,8 @@ class IssueSerializer(DynamicBaseSerializer): "link_count", "is_draft", "archived_at", + "external_source", + "external_id", ] read_only_fields = fields @@ -852,6 +854,8 @@ class IssueListDetailSerializer(serializers.Serializer): "updated_by": instance.updated_by_id, "is_draft": instance.is_draft, "archived_at": instance.archived_at, + "external_source": instance.external_source, + "external_id": instance.external_id, "source_project_name": getattr(instance, "source_project_name", None), # Computed fields "cycle_id": instance.cycle_id, 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 c5a5fea..3339e27 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -2,13 +2,18 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. +import calendar import json import re +import uuid +from datetime import date, timedelta from difflib import SequenceMatcher from html import escape from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from django.core.serializers.json import DjangoJSONEncoder +from django.db import connection, transaction +from django.db.models import Max, Q from django.utils import timezone from openai import OpenAI @@ -18,15 +23,26 @@ from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from plane.app.permissions import ROLE, allow_permission -from plane.app.serializers import IssueCreateSerializer, WorkspaceAISettingsSerializer +from plane.app.serializers import IssueCreateSerializer, IssueDetailSerializer, WorkspaceAISettingsSerializer from plane.bgtasks.issue_activities_task import issue_activity from plane.bgtasks.issue_description_version_task import issue_description_version_task from plane.bgtasks.webhook_task import model_activity from plane.db.models import ( Issue, + IssueActivity, + IssueAssignee, + IssueComment, + IssueLabel, + IssueLink, + IssueMention, + IssueRelation, + IssueSequence, Label, Project, ProjectMember, + State, + StateGroup, + UserRecentVisit, VoiceTaskSession, Workspace, WorkspaceAICredential, @@ -46,8 +62,96 @@ VOICE_TASK_MEMORY_LIMIT = 5 VOICE_TASK_CONTEXT_LIMIT = 100 VOICE_TASK_PROJECT_MATCH_THRESHOLD = 0.8 VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD = 0.8 +VOICE_TASK_STATE_MATCH_THRESHOLD = 0.8 +VOICE_TASK_EXTERNAL_SOURCE = "voice_tasker" +VOICE_TASK_PROJECT_ALIASES = { + "mgr": [ + "менеджмент", + "проект менеджмент", + "контур менеджмент", + "project management", + "management", + "manager", + ], + "buh": ["бухгалтерия", "бух", "accounting", "finance"], + "codex": ["кодекс", "codex", "voice tasker", "vt codex"], + "nodedctask": ["taskmanager", "task manager", "таск менеджер", "менеджер задач"], +} +VOICE_TASK_STATE_GROUP_HINTS = { + StateGroup.STARTED.value: [ + "в работе", + "в реализации", + "реализация", + "реализации", + "реализацию", + "активный", + "активная", + "активное", + "активные", + "активном", + "активную", + "в процессе", + "started", + "in progress", + "progress", + "work", + "active", + ], + StateGroup.UNSTARTED.value: [ + "к выполнению", + "todo", + "to do", + "не начато", + "новая", + "новый", + "новое", + "запланировано", + ], + StateGroup.BACKLOG.value: ["backlog", "беклог", "бэклог", "очередь", "потом"], + StateGroup.COMPLETED.value: ["готово", "закрыто", "завершено", "done", "completed", "closed"], + StateGroup.CANCELLED.value: ["отложено", "отмена", "отменено", "cancelled", "canceled"], +} DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") TIME_PATTERN = re.compile(r"^\d{2}:\d{2}$") +VOICE_TASK_NUMBER_WORDS = { + "один": 1, + "одна": 1, + "одно": 1, + "одну": 1, + "два": 2, + "две": 2, + "пару": 2, + "три": 3, + "четыре": 4, + "пять": 5, + "шесть": 6, + "семь": 7, + "восемь": 8, + "девять": 9, + "десять": 10, + "одиннадцать": 11, + "двенадцать": 12, + "тринадцать": 13, + "четырнадцать": 14, + "пятнадцать": 15, + "шестнадцать": 16, + "семнадцать": 17, + "восемнадцать": 18, + "девятнадцать": 19, + "двадцать": 20, + "тридцать": 30, +} +VOICE_TASK_RELATIVE_NUMBER_PATTERN = ( + r"\d+|один|одна|одно|одну|два|две|пару|три|четыре|пять|шесть|семь|восемь|девять|" + r"десять|одиннадцать|двенадцать|тринадцать|четырнадцать|пятнадцать|шестнадцать|" + r"семнадцать|восемнадцать|девятнадцать|двадцать(?:\s+(?:один|одна|одно|одну|два|две|" + r"три|четыре|пять|шесть|семь|восемь|девять))?|тридцать" +) +VOICE_TASK_RELATIVE_DATE_PATTERN = re.compile( + rf"(?{VOICE_TASK_RELATIVE_NUMBER_PATTERN})\s+)?" + r"(?PИсходная транскрибация пользователя:
{formatted_transcript}
" + + +def build_voice_task_description_html(draft, transcript=None): parts = [] + parts.append("Источник: Voice Tasker
") + if draft.get("description"): - parts.append(f"{escape(draft['description'])}
") + parts.append("Подробная постановка:
") + parts.append(f"{format_voice_task_html_text(draft['description'])}
") + elif draft.get("title"): + parts.append("Краткая постановка:
") + parts.append(f"{format_voice_task_html_text(draft['title'])}
") if draft.get("due_time"): parts.append(f"Ориентир по времени: до {escape(draft['due_time'])}
") @@ -490,14 +1261,54 @@ def build_voice_task_description_html(draft): if checklist: items = "".join(f"Checklist:
Декомпозиция:
Уточнение:
") + parts.append(f"{format_voice_task_html_text(draft['description'])}
") + + if draft.get("due_date"): + parts.append(f"Новый срок: {escape(draft['due_date'])}
") + + if draft.get("due_time"): + parts.append(f"Ориентир по времени: до {escape(draft['due_time'])}
") + + checklist = draft.get("checklist") if isinstance(draft.get("checklist"), list) else [] + if checklist: + items = "".join(f"Декомпозиция:
Voice update:
{update_html}" + + +def build_voice_task_issue_payload(draft, resolution, transcript=None): project = resolution.get("project") assignee = resolution.get("assignee") + state = resolution.get("state") labels = resolution.get("labels") or [] assignee_ids = [] if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD: @@ -505,15 +1316,159 @@ def build_voice_task_issue_payload(draft, resolution): return { "name": draft["title"], - "description_html": build_voice_task_description_html(draft), + "description_html": build_voice_task_description_html(draft, transcript), "target_date": draft.get("due_date"), "priority": draft.get("priority") or "none", "assignee_ids": assignee_ids, "label_ids": [label["id"] for label in labels], + "state_id": state["id"] if state else None, "project_id": project["id"] if project else None, } +def build_voice_task_issue_update_payload(issue, draft, resolution, transcript=None): + assignee = resolution.get("assignee") + state = resolution.get("state") + labels = resolution.get("labels") or [] + project_change = resolution.get("project_change") + payload = {} + + if draft.get("title"): + payload["name"] = draft["title"] + + update_note_html = build_voice_task_update_note_html(draft, transcript) + if update_note_html: + payload["description_html"] = append_voice_task_description(issue.description_html, update_note_html) + + if draft.get("due_date"): + payload["target_date"] = draft["due_date"] + + if draft.get("priority") and draft["priority"] != "none": + payload["priority"] = draft["priority"] + + if 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: + payload["state_id"] = state["id"] + + if labels: + if project_change: + payload["label_ids"] = [label["id"] for label in labels] + else: + existing_label_ids = [str(label_id) for label_id in issue.labels.values_list("id", flat=True)] + merged_label_ids = existing_label_ids + [label["id"] for label in labels if label["id"] not in existing_label_ids] + payload["label_ids"] = merged_label_ids + + return payload + + +def remap_voice_task_issue_labels(issue, target_project): + source_labels = list(issue.labels.all()) + if not source_labels: + return [] + + target_labels = list(Label.objects.filter(project=target_project)) + target_labels_by_name = {normalize_match_value(label.name): label for label in target_labels} + return [ + target_label.id + for source_label in source_labels + if (target_label := target_labels_by_name.get(normalize_match_value(source_label.name))) + ] + + +def get_voice_task_next_issue_sequence(project): + lock_key = int(str(project.id).replace("-", "")[:15], 16) + with connection.cursor() as cursor: + cursor.execute("SELECT pg_advisory_xact_lock(%s)", [lock_key]) + + last_sequence = IssueSequence.objects.filter(project=project).aggregate(largest=Max("sequence"))["largest"] + return last_sequence + 1 if last_sequence else 1 + + +def move_voice_task_issue_to_project(issue, target_project, target_state, actor): + if issue.project_id == target_project.id: + return issue + + workspace_id = issue.workspace_id + old_project = issue.project + target_label_ids = remap_voice_task_issue_labels(issue, target_project) + target_assignee_ids = list( + ProjectMember.objects.filter( + project=target_project, + is_active=True, + role__gte=ROLE.MEMBER.value, + member_id__in=issue.assignees.values_list("id", flat=True), + ).values_list("member_id", flat=True) + ) + + with transaction.atomic(): + issue = Issue.issue_objects.select_for_update(of=("self",)).get(id=issue.id) + next_sequence = get_voice_task_next_issue_sequence(target_project) + largest_sort_order = Issue.objects.filter(project=target_project, state_id=target_state["id"]).aggregate( + largest=Max("sort_order") + )["largest"] + + IssueSequence.objects.filter(issue=issue).update(issue=None) + IssueLabel.objects.filter(issue=issue).delete() + IssueAssignee.objects.filter(issue=issue).delete() + + issue.project = target_project + issue.state_id = target_state["id"] + issue.sequence_id = next_sequence + issue.sort_order = (largest_sort_order + 10000) if largest_sort_order is not None else 65535 + if issue.parent_id and issue.parent.project_id != target_project.id: + issue.parent = None + if issue.estimate_point_id and issue.estimate_point.project_id != target_project.id: + issue.estimate_point = None + if issue.type_id and issue.type.project_id != target_project.id: + issue.type = None + issue.save() + + IssueSequence.objects.create( + issue=issue, + sequence=issue.sequence_id, + project=target_project, + created_by_id=actor.id, + ) + IssueLabel.objects.bulk_create( + [ + IssueLabel( + label_id=label_id, + issue=issue, + project=target_project, + workspace_id=workspace_id, + created_by_id=actor.id, + updated_by_id=actor.id, + ) + for label_id in target_label_ids + ], + ignore_conflicts=True, + ) + IssueAssignee.objects.bulk_create( + [ + IssueAssignee( + assignee_id=assignee_id, + issue=issue, + project=target_project, + workspace_id=workspace_id, + created_by_id=actor.id, + updated_by_id=actor.id, + ) + for assignee_id in target_assignee_ids + ], + ignore_conflicts=True, + ) + + for relation_model in [IssueComment, IssueLink, IssueMention, IssueRelation, IssueActivity]: + relation_model.objects.filter(issue=issue, project=old_project).update( + project=target_project, + workspace_id=workspace_id, + ) + + return issue + + def serialize_workspace_members(workspace): members = WorkspaceMember.objects.filter( workspace=workspace, @@ -545,19 +1500,29 @@ def serialize_recent_voice_memory(workspace, user): status=VoiceTaskSession.Status.PARSED, ) .exclude(parsed_json={}) - .order_by("-created_at")[:VOICE_TASK_MEMORY_LIMIT] + .select_related("created_task", "created_task__project", "updated_task", "updated_task__project") + .order_by("-updated_at", "-created_at")[:VOICE_TASK_MEMORY_LIMIT] ) - return [ - { - "voice_session_id": str(session.id), - "intent": session.intent, - "title": session.parsed_json.get("title"), - "project_hint": session.parsed_json.get("project_hint"), - "created_at": session.created_at.isoformat(), - } - for session in sessions - ] + memory = [] + for session in sessions: + target_issue = get_voice_session_target_issue(session) + target_task = ( + serialize_voice_task_target(target_issue, "recent_voice_memory", session) + if is_voice_task_issue_available(target_issue) + else None + ) + memory.append( + { + "voice_session_id": str(session.id), + "intent": session.intent, + "title": session.parsed_json.get("title"), + "project_hint": session.parsed_json.get("project_hint"), + "target_task": target_task, + "created_at": session.created_at.isoformat(), + } + ) + return memory def build_voice_task_parser_context(workspace, user, transcript, client_context): @@ -639,6 +1604,7 @@ def normalize_voice_task_parse(parsed): "intent": intent, "target_memory_ref": normalize_string(parsed.get("target_memory_ref"), 80), "project_hint": normalize_string(parsed.get("project_hint"), 255), + "state_hint": normalize_string(parsed.get("state_hint"), 120), "assignee_hint": normalize_string(parsed.get("assignee_hint"), 255), "title": normalize_string(parsed.get("title"), 255), "description": normalize_string(parsed.get("description")), @@ -895,6 +1861,11 @@ class VoiceTaskParseEndpoint(BaseAPIView): api_key=api_key, model=ai_settings.structuring_model, ).parse(parser_context) + if not parsed.get("state_hint"): + inferred_state_hint = infer_voice_task_state_hint(transcript) + if inferred_state_hint: + parsed["state_hint"] = inferred_state_hint + warnings = get_voice_task_warnings(parsed, transcript) resolution = build_voice_task_resolution( workspace=workspace, @@ -902,6 +1873,7 @@ class VoiceTaskParseEndpoint(BaseAPIView): ai_settings=ai_settings, draft=parsed, client_context=client_context, + transcript=transcript, ) warnings = list(dict.fromkeys(warnings + resolution["warnings"])) requires_confirmation = voice_task_requires_confirmation(parsed, warnings) @@ -1004,11 +1976,15 @@ class VoiceTaskCommitEndpoint(BaseAPIView): {"ok": False, "code": exc.code, "error": exc.message}, status=exc.response_status, ) + if not draft.get("state_hint"): + inferred_state_hint = infer_voice_task_state_hint(voice_session.transcript) + if inferred_state_hint: + draft["state_hint"] = inferred_state_hint action = request.data.get("action") or draft["intent"] - if action != "create_task" or draft["intent"] != "create_task": + if action not in {"create_task", "update_task", "delete_task"} or action != draft["intent"]: return Response( - {"ok": False, "code": "unsupported_intent", "error": "Only create_task commit is supported now."}, + {"ok": False, "code": "unsupported_intent", "error": "Voice Task commit action is not supported."}, status=status.HTTP_400_BAD_REQUEST, ) @@ -1019,80 +1995,218 @@ class VoiceTaskCommitEndpoint(BaseAPIView): ai_settings=ai_settings, draft=draft, client_context=voice_session.client_context or {}, + voice_session=voice_session, ) if not resolution["can_commit"]: return Response( { "ok": False, "code": "draft_not_resolved", - "error": "Voice Task draft is not resolved enough to create a work item.", + "error": "Voice Task draft is not resolved enough to commit.", "resolution": resolution, }, status=status.HTTP_400_BAD_REQUEST, ) - project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) - payload = build_voice_task_issue_payload(draft, resolution) - payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} + if action == "create_task": + project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace) + payload = build_voice_task_issue_payload(draft, resolution, voice_session.transcript) + payload_without_project = {key: value for key, value in payload.items() if key != "project_id"} + payload_without_project["external_source"] = VOICE_TASK_EXTERNAL_SOURCE + payload_without_project["external_id"] = str(voice_session.id) - serializer = IssueCreateSerializer( - data=payload_without_project, - context={ - "project_id": project.id, - "workspace_id": workspace.id, - "default_assignee_id": project.default_assignee_id, - }, - ) - if not serializer.is_valid(): - return Response( - { - "ok": False, - "code": "issue_validation_failed", - "error": "Voice Task draft could not be converted to a work item.", - "details": serializer.errors, + serializer = IssueCreateSerializer( + data=payload_without_project, + context={ + "project_id": project.id, + "workspace_id": workspace.id, + "default_assignee_id": project.default_assignee_id, }, - status=status.HTTP_400_BAD_REQUEST, + ) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task draft could not be converted to a work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue = serializer.save(created_by_id=request.user.id) + voice_session.created_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + + requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder) + issue_activity.delay( + type="issue.activity.created", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance=None, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(issue.id), + requested_data=payload_without_project, + current_instance=None, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + issue_description_version_task.delay( + updated_issue=requested_data, + issue_id=str(issue.id), + user_id=request.user.id, + is_creating=True, ) - issue = serializer.save(created_by_id=request.user.id) - voice_session.created_task = issue - voice_session.parsed_json = draft - voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"]) + response_status = status.HTTP_201_CREATED + commit_status = "created" - requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder) - issue_activity.delay( - type="issue.activity.created", - requested_data=requested_data, - actor_id=str(request.user.id), - issue_id=str(issue.id), - project_id=str(project.id), - current_instance=None, - epoch=int(timezone.now().timestamp()), - notification=True, - origin=base_host(request=request, is_app=True), - ) - model_activity.delay( - model_name="issue", - model_id=str(issue.id), - requested_data=payload_without_project, - current_instance=None, - actor_id=request.user.id, - slug=slug, - origin=base_host(request=request, is_app=True), - ) - issue_description_version_task.delay( - updated_issue=requested_data, - issue_id=str(issue.id), - user_id=request.user.id, - is_creating=True, - ) + elif action == "update_task": + target_task = resolution.get("target_task") or {} + issue = ( + Issue.issue_objects.filter(id=target_task.get("id"), workspace=workspace) + .select_related("project") + .first() + ) + if not issue: + return Response( + {"ok": False, "code": "target_task_not_found", "error": "Target task was not found."}, + status=status.HTTP_404_NOT_FOUND, + ) - issue_key = f"{project.identifier}-{issue.sequence_id}" - task_url = f"/{slug}/browse/{issue_key}/" + project_change = resolution.get("project_change") + project = ( + Project.objects.get(id=resolution["project"]["id"], workspace=workspace) + if project_change + else issue.project + ) + payload = build_voice_task_issue_update_payload(issue, draft, resolution, voice_session.transcript) + payload["external_source"] = VOICE_TASK_EXTERNAL_SOURCE + payload["external_id"] = str(voice_session.id) + current_instance = json.dumps(IssueDetailSerializer(issue).data, cls=DjangoJSONEncoder) + requested_payload = ( + {**payload, "project_id": str(project.id)} + if project_change + else payload + ) + requested_data = json.dumps(requested_payload, cls=DjangoJSONEncoder) + serializer = IssueCreateSerializer(issue, data=payload, partial=True, context={"project_id": project.id}) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task update could not be applied to the work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + if project_change: + issue = move_voice_task_issue_to_project(issue, project, resolution["state"], request.user) + serializer = IssueCreateSerializer(issue, data=payload, partial=True, context={"project_id": project.id}) + if not serializer.is_valid(): + return Response( + { + "ok": False, + "code": "issue_validation_failed", + "error": "Voice Task update could not be applied to the work item.", + "details": serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer.save() + issue.refresh_from_db() + voice_session.updated_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["updated_task", "parsed_json", "updated_at"]) + + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance=current_instance, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + ) + model_activity.delay( + model_name="issue", + model_id=str(issue.id), + requested_data=requested_payload, + current_instance=current_instance, + actor_id=request.user.id, + slug=slug, + origin=base_host(request=request, is_app=True), + ) + issue_description_version_task.delay( + updated_issue=current_instance, + issue_id=str(issue.id), + user_id=request.user.id, + ) + + response_status = status.HTTP_200_OK + commit_status = "updated" + + else: + target_task = resolution.get("target_task") or {} + issue = ( + Issue.issue_objects.filter(id=target_task.get("id"), workspace=workspace) + .select_related("project") + .first() + ) + if not issue: + return Response( + {"ok": False, "code": "target_task_not_found", "error": "Target task was not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + project = issue.project + issue.delete() + UserRecentVisit.objects.filter( + project_id=project.id, + workspace=workspace, + entity_identifier=issue.id, + entity_name="issue", + ).delete(soft=False) + voice_session.updated_task = issue + voice_session.parsed_json = draft + voice_session.save(update_fields=["updated_task", "parsed_json", "updated_at"]) + + issue_activity.delay( + type="issue.activity.deleted", + requested_data=json.dumps({"issue_id": str(issue.id)}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project.id), + current_instance={}, + epoch=int(timezone.now().timestamp()), + notification=True, + origin=base_host(request=request, is_app=True), + subscriber=False, + ) + + response_status = status.HTTP_200_OK + commit_status = "deleted" + + issue_key = build_issue_key(issue) + task_url = f"/{slug}/browse/{issue_key}/" if issue_key else None return Response( { "ok": True, - "status": "created", + "status": commit_status, "voice_session_id": str(voice_session.id), "task_id": str(issue.id), "task_key": issue_key, @@ -1101,5 +2215,5 @@ class VoiceTaskCommitEndpoint(BaseAPIView): "sequence_id": issue.sequence_id, "resolution": resolution, }, - status=status.HTTP_201_CREATED, + status=response_status, ) diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/block.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/block.tsx index 7a0e945..d069f59 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/kanban/block.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/kanban/block.tsx @@ -12,7 +12,7 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { useOutsideClickDetector } from "@plane/hooks"; // plane helpers -import { MoreHorizontal } from "lucide-react"; +import { Mic, MoreHorizontal } from "lucide-react"; // types import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { Tooltip } from "@plane/propel/tooltip"; @@ -99,6 +99,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props: // derived values const subIssueCount = issue?.sub_issues_count ?? 0; + const isVoiceTask = issue?.external_source === "voice_tasker"; const handleEventPropagation = (e: React.MouseEvent) => { e.stopPropagation(); @@ -151,6 +152,13 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props: + {isVoiceTask && ( +