ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript

This commit is contained in:
DCCONSTRUCTIONS 2026-04-24 21:54:34 +03:00
parent d3b47326da
commit 597480adb9
9 changed files with 1629 additions and 227 deletions

View File

@ -52,11 +52,12 @@ Voice Tasker не создает отдельную модель задачи.
1. workspace AI settings; 1. workspace AI settings;
2. encrypted workspace credentials; 2. encrypted workspace credentials;
3. voice sessions; 3. voice sessions.
4. voice memory.
Не создавать отдельные `VoiceTask`, `VoiceProject`, `VoiceUser`, `VoiceInbox` как обязательные бизнес-сущности. Не создавать отдельные `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 ### 2.2. Backend-only OpenAI
Frontend: Frontend:
@ -75,7 +76,7 @@ Backend:
- валидирует JSON; - валидирует JSON;
- резолвит project/member; - резолвит project/member;
- создает/редактирует work item через внутренние Plane модели/serializer/service; - создает/редактирует work item через внутренние Plane модели/serializer/service;
- пишет voice session и memory. - пишет voice session и session-backed memory.
### 2.3. Не использовать внешний Plane REST API ### 2.3. Не использовать внешний Plane REST API
@ -148,10 +149,27 @@ Native поля "deadline time" у work item сейчас нет.
2. фраза "до 15:00" сохраняется в draft как `due_time`; 2. фраза "до 15:00" сохраняется в draft как `due_time`;
3. при commit `due_time` не создает новое поле в `Issue`; 3. при commit `due_time` не создает новое поле в `Issue`;
4. если время важно, оно добавляется в `description_html` отдельной строкой, например: `Ориентир по времени: до 15:00`; 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 как архитектурное расширение. Отдельное 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. Пользовательские сценарии ## 4. Пользовательские сценарии
@ -170,6 +188,7 @@ Parser возвращает draft:
{ {
"intent": "create_task", "intent": "create_task",
"project_hint": "контур бухгалтерии", "project_hint": "контур бухгалтерии",
"state_hint": null,
"assignee_hint": "Настя / бухгалтер Настя", "assignee_hint": "Настя / бухгалтер Настя",
"title": "Подготовить декларацию по НДС", "title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.", "description": "Необходимо подготовить декларацию по НДС.",
@ -202,10 +221,13 @@ Commit маппит draft в Plane:
MVP-правило: MVP-правило:
1. если `project_confidence >= 0.8`, можно auto-create; 1. backend сначала ищет проект в самом transcript по именам, identifiers и утвержденным алиасам;
2. если проект не найден уверенно, показываем preview с ручным выбором project; 2. transcript-резолв имеет приоритет над `project_hint`, потому что модель может галлюцинировать текущий проект;
3. если admin заранее указал `default_project_id`, можно предложить его как fallback; 3. если `project_hint` найден уверенно и не конфликтует с transcript, можно auto-create;
4. если fallback project не задан, задачу не создаем автоматически. 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 низкий; 3. показывает preview изменения, если confidence низкий;
4. меняет `Issue.target_date`; 4. меняет `Issue.target_date`;
5. сохраняет `due_time` в description note / parsed JSON; 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-задачи ### 4.5. Удаление последней voice-задачи
@ -249,7 +280,7 @@ MVP-правило:
- удаление всегда требует confirmation modal; - удаление всегда требует confirmation modal;
- backend повторно проверяет права на удаление; - backend повторно проверяет права на удаление;
- действие пишется в voice memory; - действие пишется в session-backed memory;
- предпочтительно использовать тот же delete path, что обычный work item, чтобы сохранился activity log. - предпочтительно использовать тот же delete path, что обычный work item, чтобы сохранился activity log.
--- ---
@ -294,8 +325,8 @@ recording - идет запись
uploading - отправка аудио uploading - отправка аудио
processing - транскрибация и разбор processing - транскрибация и разбор
success - draft разобран success - draft разобран
committing - создание задачи committing - применение voice-действия
committed - задача создана / обновлена committed - задача создана / обновлена / удалена
error - ошибка error - ошибка
``` ```
@ -316,7 +347,7 @@ error - ошибка
Кнопки: Кнопки:
- `Создать задачу` / `Применить изменения`; - `Создать задачу` / `Применить изменения` / `Удалить задачу`;
- `Редактировать`; - `Редактировать`;
- `Отмена`. - `Отмена`.
@ -397,7 +428,6 @@ Backend:
GET /api/workspaces/:workspaceSlug/voice-task/preflight GET /api/workspaces/:workspaceSlug/voice-task/preflight
POST /api/workspaces/:workspaceSlug/voice-task/parse POST /api/workspaces/:workspaceSlug/voice-task/parse
POST /api/workspaces/:workspaceSlug/voice-task/commit POST /api/workspaces/:workspaceSlug/voice-task/commit
POST /api/workspaces/:workspaceSlug/voice-task/resolve-command
``` ```
### 7.1. Preflight ### 7.1. Preflight
@ -473,6 +503,7 @@ Response:
"intent": "create_task", "intent": "create_task",
"target_memory_ref": null, "target_memory_ref": null,
"project_hint": "контур бухгалтерии", "project_hint": "контур бухгалтерии",
"state_hint": null,
"assignee_hint": "Настя", "assignee_hint": "Настя",
"title": "Подготовить декларацию по НДС", "title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.", "description": "Необходимо подготовить декларацию по НДС.",
@ -497,6 +528,13 @@ Response:
"confidence": 0.91, "confidence": 0.91,
"source": "project_hint" "source": "project_hint"
}, },
"state": {
"id": "state_uuid",
"name": "К выполнению",
"group": "unstarted",
"confidence": 0.65,
"source": "default_open_state"
},
"assignee": { "assignee": {
"id": "user_uuid", "id": "user_uuid",
"name": "Настя", "name": "Настя",
@ -510,6 +548,7 @@ Response:
"name": "voice" "name": "voice"
} }
], ],
"target_task": null,
"warnings": [], "warnings": [],
"can_commit": true "can_commit": true
}, },
@ -536,11 +575,12 @@ Payload:
```json ```json
{ {
"voice_session_id": "uuid", "voice_session_id": "uuid",
"action": "create_task", "action": "create_task | update_task | delete_task",
"draft": { "draft": {
"title": "Подготовить декларацию по НДС", "title": "Подготовить декларацию по НДС",
"description": "Необходимо подготовить декларацию по НДС.", "description": "Необходимо подготовить декларацию по НДС.",
"project_id": "project_uuid", "project_id": "project_uuid",
"state_hint": null,
"assignee_ids": ["user_uuid"], "assignee_ids": ["user_uuid"],
"due_date": "2026-04-24", "due_date": "2026-04-24",
"due_time": "15:00", "due_time": "15:00",
@ -558,6 +598,7 @@ Internal Plane payload:
"description_html": "<p>Необходимо подготовить декларацию по НДС.</p><p><strong>Ориентир по времени:</strong> до 15:00</p>", "description_html": "<p>Необходимо подготовить декларацию по НДС.</p><p><strong>Ориентир по времени:</strong> до 15:00</p>",
"target_date": "2026-04-24", "target_date": "2026-04-24",
"priority": "high", "priority": "high",
"state_id": "state_uuid",
"assignees": ["user_uuid"], "assignees": ["user_uuid"],
"labels": ["label_uuid"] "labels": ["label_uuid"]
} }
@ -583,6 +624,13 @@ Response:
"confidence": 0.91, "confidence": 0.91,
"source": "project_hint" "source": "project_hint"
}, },
"state": {
"id": "state_uuid",
"name": "К выполнению",
"group": "unstarted",
"confidence": 0.65,
"source": "default_open_state"
},
"assignee": { "assignee": {
"id": "user_uuid", "id": "user_uuid",
"name": "Настя", "name": "Настя",
@ -591,13 +639,20 @@ Response:
"source": "assignee_hint" "source": "assignee_hint"
}, },
"labels": [{ "id": "label_uuid", "name": "voice" }], "labels": [{ "id": "label_uuid", "name": "voice" }],
"target_task": null,
"warnings": [], "warnings": [],
"can_commit": true "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. 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 ```txt
id voice_task_sessions.workspace_id
workspace_id voice_task_sessions.user_id
user_id voice_task_sessions.intent
task_id voice_task_sessions.parsed_json
voice_session_id voice_task_sessions.created_task_id
action voice_task_sessions.updated_task_id
summary voice_task_sessions.created_at
created_at voice_task_sessions.updated_at
``` ```
Использование: Использование:
- хранить последние N voice-действий пользователя; - хранить последние N voice-действий пользователя;
- N по умолчанию: 10; - N по умолчанию для parser context: 5;
- резолвить "последняя задача", "предыдущая задача", "та задача в бухгалтерии"; - резолвить "последняя задача", "предыдущая задача", "та задача в бухгалтерии";
- не использовать memory как источник истины вместо `Issue`. - не использовать memory как источник истины вместо `Issue`.
Если в будущем понадобится отдельная агрегированная history/memory-таблица, она должна быть добавлена отдельным архитектурным этапом и не должна дублировать `Issue` как источник истины.
--- ---
## 9. OpenAI pipeline ## 9. OpenAI pipeline
@ -741,7 +800,26 @@ Input:
"transcript": "...", "transcript": "...",
"workspace_projects": [], "workspace_projects": [],
"workspace_members": [], "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", "current_date": "2026-04-24",
"timezone": "Europe/Moscow" "timezone": "Europe/Moscow"
} }
@ -752,7 +830,7 @@ Output строго JSON:
```json ```json
{ {
"intent": "create_task | update_task | delete_task | unknown", "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", "project_hint": "string | null",
"assignee_hint": "string | null", "assignee_hint": "string | null",
"title": "string | null", "title": "string | null",
@ -789,20 +867,37 @@ Return JSON only.
Вход: Вход:
- `project_hint`; - `project_hint`;
- transcript;
- список проектов workspace, доступных пользователю; - список проектов workspace, доступных пользователю;
- `current_project_id`, если пользователь находится внутри проекта; - `current_project_id`, если пользователь находится внутри проекта;
- `default_project_id` из settings. - `default_project_id` из settings.
Логика: Логика:
1. exact match по имени/identifier; 1. exact/alias match по transcript: имя проекта, identifier, короткие алиасы (`MGR`, `BUH`, `CODEX`) и согласованные русские UX-формы вроде "контур менеджмент";
2. fuzzy match по имени; 2. transcript match имеет приоритет над `project_hint`;
3. current project как слабый fallback; 3. exact/fuzzy match по `project_hint` и candidates проекта;
4. default project как fallback, если задан; 4. current project как слабый fallback только если пользователь не назвал проект/контур явно;
5. если confidence низкий - preview с ручным выбором. 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`. Не зашивать термин "контур" как обязательный. Для 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 ### 10.2. Assignee resolver
Вход: Вход:
@ -820,15 +915,60 @@ Return JSON only.
Назначать можно только пользователей, которые состоят в project и имеют достаточную роль. Назначать можно только пользователей, которые состоят в 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: MVP:
- сегодня; - сегодня;
- завтра; - завтра;
- послезавтра / вчера / позавчера;
- `N` дней/недель/месяцев/лет вперед;
- `N` дней/недель/месяцев/лет назад;
- сложные интервалы: "два месяца и две недели";
- числительные цифрами и частые русские словоформы: "2 недели", "две недели", "пару дней";
- защита от ложных матчей внутри слов: "последней" не считается как "дней";
- конкретная дата; - конкретная дата;
- конкретное время как `due_time` note. - конкретное время как `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: Backlog:
- к пятнице; - к пятнице;
@ -927,7 +1067,7 @@ MVP:
- хранить transcript N дней; - хранить transcript N дней;
- очищать transcript после commit; - очищать transcript после commit;
- хранить только parsed JSON; - хранить только parsed JSON;
- отключать voice memory для sensitive workspace. - отключать session-backed voice memory для sensitive workspace.
--- ---
@ -991,6 +1131,7 @@ voice_task.error
### Stage 4 - Preview и создание задачи ### Stage 4 - Preview и создание задачи
- project resolver; - project resolver;
- state resolver и безопасный default open-state;
- assignee resolver; - assignee resolver;
- date resolver MVP; - date resolver MVP;
- preview modal; - preview modal;
@ -998,14 +1139,19 @@ voice_task.error
- создание `Issue` через внутренний Plane layer; - создание `Issue` через внутренний Plane layer;
- activity log/model activity как у обычного work item; - activity log/model activity как у обычного work item;
- точечное обновление активного issue-store после commit без reload/polling; - точечное обновление активного issue-store после commit без reload/polling;
- `voice_task_memory` для created action. - session-backed memory для created action.
### Stage 5 - Memory commands ### Stage 5 - Memory commands
- update last task; - update last task;
- delete last task with confirmation; - delete last task with confirmation;
- append description/checklist to last task; - 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; - Voice Inbox как отдельный управляемый fallback project/triage flow;
- Redis/Celery-backed VoiceTaskQueue; - Redis/Celery-backed VoiceTaskQueue;
- transcript retention policies; - transcript retention policies;
- project/member aliases; - расширяемые project/member aliases в настройках workspace;
- выбранные роли beyond `all members/admins only`; - выбранные роли beyond `all members/admins only`;
- monthly budget/soft cap; - monthly budget/soft cap;
- multi-provider AI; - multi-provider AI;
@ -1039,18 +1185,22 @@ voice_task.error
7. Backend транскрибирует через OpenAI. 7. Backend транскрибирует через OpenAI.
8. Backend формирует валидный structured draft. 8. Backend формирует валидный structured draft.
9. Project resolver выбирает project или требует ручной выбор. 9. Project resolver выбирает project или требует ручной выбор.
10. Assignee resolver назначает только уверенно найденного project member. 10. State resolver выбирает явный state или безопасный default open-state.
11. Если assignee не найден - задача может быть создана без assignee. 11. Assignee resolver назначает только уверенно найденного project member.
12. Commit создает обычную `Issue` через внутренний Plane backend layer. 12. Если assignee не найден - задача может быть создана без assignee.
13. После commit активная доска/список обновляется без reload страницы. 13. Commit создает обычную `Issue` через внутренний Plane backend layer.
14. `due_date` маппится в `target_date`. 14. После commit активная доска/список обновляется без reload страницы.
15. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP. 15. `due_date` маппится в `target_date`.
15. Voice session сохраняется. 16. `due_time` сохраняется как note в description и parsed JSON, без новой колонки в MVP.
16. Последняя voice-задача сохраняется в memory. 17. Voice session сохраняется.
17. Update last task работает минимум для `target_date` и description. 18. Последняя voice-задача сохраняется в session-backed memory.
18. Delete last task требует confirmation. 19. Update last task работает минимум для `target_date`, state и description.
19. User/workspace limits работают. 20. Delete last task требует confirmation.
20. OpenAI errors показываются пользователю как нормальные сообщения без raw stack trace. 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.
--- ---

View File

@ -799,6 +799,8 @@ class IssueSerializer(DynamicBaseSerializer):
"link_count", "link_count",
"is_draft", "is_draft",
"archived_at", "archived_at",
"external_source",
"external_id",
] ]
read_only_fields = fields read_only_fields = fields
@ -852,6 +854,8 @@ class IssueListDetailSerializer(serializers.Serializer):
"updated_by": instance.updated_by_id, "updated_by": instance.updated_by_id,
"is_draft": instance.is_draft, "is_draft": instance.is_draft,
"archived_at": instance.archived_at, "archived_at": instance.archived_at,
"external_source": instance.external_source,
"external_id": instance.external_id,
"source_project_name": getattr(instance, "source_project_name", None), "source_project_name": getattr(instance, "source_project_name", None),
# Computed fields # Computed fields
"cycle_id": instance.cycle_id, "cycle_id": instance.cycle_id,

File diff suppressed because it is too large Load Diff

View File

@ -12,7 +12,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers // plane helpers
import { MoreHorizontal } from "lucide-react"; import { Mic, MoreHorizontal } from "lucide-react";
// types // types
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
@ -99,6 +99,7 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
// derived values // derived values
const subIssueCount = issue?.sub_issues_count ?? 0; const subIssueCount = issue?.sub_issues_count ?? 0;
const isVoiceTask = issue?.external_source === "voice_tasker";
const handleEventPropagation = (e: React.MouseEvent) => { const handleEventPropagation = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -151,6 +152,13 @@ const KanbanIssueDetailsBlock = observer(function KanbanIssueDetailsBlock(props:
</div> </div>
</Tooltip> </Tooltip>
{isVoiceTask && (
<div className="inline-flex max-w-full items-center gap-1 rounded border border-pink-500/30 bg-pink-500/10 px-1.5 py-0.5 text-11 font-medium text-pink-300">
<Mic className="h-3 w-3 shrink-0" />
<span>Voice</span>
</div>
)}
<IssueProperties <IssueProperties
className="flex flex-wrap items-center gap-2 pt-1.5 whitespace-nowrap text-tertiary" className="flex flex-wrap items-center gap-2 pt-1.5 whitespace-nowrap text-tertiary"
issue={issue} issue={issue}

View File

@ -7,7 +7,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import useSWR from "swr"; import useSWR from "swr";
import { CheckCircle2, Mic, Plus, RotateCcw, Square, Upload, X } from "lucide-react"; import { CheckCircle2, Mic, Pencil, Plus, RotateCcw, Square, Trash2, Upload, X } from "lucide-react";
// plane imports // plane imports
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
@ -61,6 +61,43 @@ function getRouteParam(value: string | string[] | undefined) {
return value?.toString(); return value?.toString();
} }
function getCommitButtonLabel(intent?: string) {
if (intent === "update_task") return "Применить изменения";
if (intent === "delete_task") return "Удалить задачу";
return "Создать задачу";
}
function getCommitStatusLabel(status: TVoiceTaskerStatus) {
if (status === "committed") return "Committed";
if (status === "success") return "Draft parsed";
if (status === "committing") return "Committing";
if (status === "uploading") return "Processing";
if (status === "recording") return "Recording";
if (status === "error") return "Error";
return "Ready";
}
function getCommitSuccessTitle(result: TVoiceTaskCommitResult) {
if (result.status === "updated") return "Задача обновлена";
if (result.status === "deleted") return "Задача удалена";
return "Задача создана";
}
function getCommitSuccessMessage(result: TVoiceTaskCommitResult) {
if (result.task_key && result.status === "updated") return `Обновлена ${result.task_key}`;
if (result.task_key && result.status === "deleted") return `Удалена ${result.task_key}`;
if (result.task_key) return `Создана ${result.task_key}`;
if (result.status === "updated") return "Work item обновлен.";
if (result.status === "deleted") return "Work item удален.";
return "Work item создан.";
}
function getVoiceTaskWarnings(result: TVoiceTaskUploadResult) {
return Array.from(
new Set([...(result.warnings ?? []), ...(result.resolution?.warnings ?? []), ...(result.draft?.questions ?? [])])
);
}
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
}; };
@ -174,7 +211,11 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
}, [audioBlob]); }, [audioBlob]);
const startRecording = async () => { const startRecording = async () => {
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") { if (
typeof navigator === "undefined" ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === "undefined"
) {
setError("Браузер не поддерживает запись аудио."); setError("Браузер не поддерживает запись аудио.");
setStatus("error"); setStatus("error");
return; return;
@ -230,6 +271,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("uploading"); setStatus("uploading");
setError(null); setError(null);
setParseResult(null); setParseResult(null);
setCommitResult(null);
const audioType = audioBlob.type || "audio/webm"; const audioType = audioBlob.type || "audio/webm";
const extension = audioType.includes("mp4") ? "m4a" : "webm"; const extension = audioType.includes("mp4") ? "m4a" : "webm";
@ -256,7 +298,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
message: "Transcript и draft получены.", message: "Transcript и draft получены.",
}); });
} catch (err) { } catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио."; const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось отправить аудио.";
setError(message); setError(message);
setStatus("error"); setStatus("error");
setToast({ setToast({
@ -299,13 +342,23 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
const commitVoiceTask = async () => { const commitVoiceTask = async () => {
if (!parseResult?.voice_session_id || !parseResult.draft) return; if (!parseResult?.voice_session_id || !parseResult.draft) return;
const action = parseResult.draft.intent;
if (action === "unknown") return;
if (action === "delete_task") {
const targetTitle =
parseResult.resolution?.target_task?.key || parseResult.resolution?.target_task?.title || "последнюю задачу";
const confirmed = window.confirm(`Удалить ${targetTitle}?`);
if (!confirmed) return;
}
setStatus("committing"); setStatus("committing");
setError(null); setError(null);
try { try {
const result = await workspaceAIService.commitVoiceTask(workspaceSlug, { const result = await workspaceAIService.commitVoiceTask(workspaceSlug, {
voice_session_id: parseResult.voice_session_id, voice_session_id: parseResult.voice_session_id,
action: "create_task", action,
draft: parseResult.draft, draft: parseResult.draft,
}); });
await refreshVisibleIssueStores(result.project_id); await refreshVisibleIssueStores(result.project_id);
@ -313,16 +366,17 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
setStatus("committed"); setStatus("committed");
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Задача создана", title: getCommitSuccessTitle(result),
message: result.task_key ? `Создана ${result.task_key}` : "Work item создан.", message: getCommitSuccessMessage(result),
}); });
} catch (err) { } catch (err) {
const message = typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось создать задачу."; const message =
typeof err === "object" && err && "error" in err ? String(err.error) : "Не удалось применить Voice Task.";
setError(message); setError(message);
setStatus("error"); setStatus("error");
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Задача не создана", title: "Voice Task не применен",
message, message,
}); });
} }
@ -330,14 +384,14 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
return ( return (
<> <>
<div className="pointer-events-none fixed right-4 z-[29] bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)]"> <div className="pointer-events-none fixed right-4 bottom-[calc(var(--nodedc-bottom-dock-offset,0px)+1rem)] z-[29]">
<Tooltip tooltipContent={tooltipContent} position="left"> <Tooltip tooltipContent={tooltipContent} position="left">
<button <button
type="button" type="button"
className={cn( className={cn(
"pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] shadow-lg transition", "shadow-lg pointer-events-auto flex size-11 items-center justify-center rounded-full border-[0.5px] transition",
isAvailable isAvailable
? "border-pink-500/40 bg-pink-500 text-white hover:bg-pink-600" ? "border-pink-500/40 bg-pink-500 hover:bg-pink-600 text-white"
: "cursor-not-allowed border-subtle bg-layer-2 text-tertiary" : "cursor-not-allowed border-subtle bg-layer-2 text-tertiary"
)} )}
disabled={!isAvailable} disabled={!isAvailable}
@ -368,19 +422,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div> <div className="text-24 font-semibold text-primary">{formatDuration(duration)}</div>
<div className="mt-1 text-12 text-tertiary"> <div className="mt-1 text-12 text-tertiary">{getCommitStatusLabel(status)}</div>
{status === "committed"
? "Created"
: status === "success"
? "Draft parsed"
: isCommitting
? "Creating"
: isUploading
? "Processing"
: isRecording
? "Recording"
: "Ready"}
</div>
</div> </div>
<div <div
className={cn( className={cn(
@ -399,7 +441,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)} )}
{error && ( {error && (
<div className="mt-4 rounded-md border-[0.5px] border-red-500/30 bg-red-500/10 px-3 py-2 text-12 text-red-500"> <div className="border-red-500/30 bg-red-500/10 text-red-500 mt-4 rounded-md border-[0.5px] px-3 py-2 text-12">
{error} {error}
</div> </div>
)} )}
@ -407,13 +449,13 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{parseResult?.draft && ( {parseResult?.draft && (
<div className="mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3"> <div className="mt-4 space-y-3 rounded-md border-[0.5px] border-subtle bg-layer-2 p-3">
<div className="flex items-center gap-2 text-13 font-medium text-primary"> <div className="flex items-center gap-2 text-13 font-medium text-primary">
<CheckCircle2 className="size-4 text-green-500" /> <CheckCircle2 className="text-green-500 size-4" />
Draft готов Draft готов
</div> </div>
{parseResult.transcript && ( {parseResult.transcript && (
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Транскрипт</div> <div className="text-11 font-medium text-tertiary uppercase">Транскрипт</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary"> <p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.transcript} {parseResult.transcript}
</p> </p>
@ -422,40 +464,66 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
<div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 text-12 sm:grid-cols-2">
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Название</div> <div className="text-11 font-medium text-tertiary uppercase">Название</div>
<div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div> <div className="mt-0.5 text-primary">{parseResult.draft.title || "не распознано"}</div>
</div> </div>
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Intent</div> <div className="text-11 font-medium text-tertiary uppercase">Intent</div>
<div className="mt-0.5 text-primary">{parseResult.draft.intent}</div> <div className="mt-0.5 text-primary">{parseResult.draft.intent}</div>
</div> </div>
{parseResult.resolution?.target_task && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Целевая задача</div>
<div className="mt-0.5 text-primary">
{[parseResult.resolution.target_task.key, parseResult.resolution.target_task.title]
.filter(Boolean)
.join(" · ")}
</div>
</div>
)}
{parseResult.resolution?.project_change && (
<div className="sm:col-span-2">
<div className="text-11 font-medium text-tertiary uppercase">Перенос проекта</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution.project_change.from.name} -&gt;{" "}
{parseResult.resolution.project_change.to.name}
</div>
</div>
)}
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Проект</div> <div className="text-11 font-medium text-tertiary uppercase">Проект</div>
<div className="mt-0.5 text-primary"> <div className="mt-0.5 text-primary">
{parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"} {parseResult.resolution?.project?.name || parseResult.draft.project_hint || "не распознано"}
</div> </div>
</div> </div>
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Исполнитель</div> <div className="text-11 font-medium text-tertiary uppercase">Статус</div>
<div className="mt-0.5 text-primary">
{parseResult.resolution?.state?.name || parseResult.draft.state_hint || "не распознано"}
</div>
</div>
<div>
<div className="text-11 font-medium text-tertiary uppercase">Исполнитель</div>
<div className="mt-0.5 text-primary"> <div className="mt-0.5 text-primary">
{parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"} {parseResult.resolution?.assignee?.name || parseResult.draft.assignee_hint || "не распознано"}
</div> </div>
</div> </div>
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Срок</div> <div className="text-11 font-medium text-tertiary uppercase">Срок</div>
<div className="mt-0.5 text-primary"> <div className="mt-0.5 text-primary">
{[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") || "не распознано"} {[parseResult.draft.due_date, parseResult.draft.due_time].filter(Boolean).join(" ") ||
"не распознано"}
</div> </div>
</div> </div>
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Приоритет</div> <div className="text-11 font-medium text-tertiary uppercase">Приоритет</div>
<div className="mt-0.5 text-primary">{parseResult.draft.priority || "не распознано"}</div> <div className="mt-0.5 text-primary">{parseResult.draft.priority || "не распознано"}</div>
</div> </div>
</div> </div>
{parseResult.draft.description && ( {parseResult.draft.description && (
<div> <div>
<div className="text-11 font-medium uppercase text-tertiary">Описание</div> <div className="text-11 font-medium text-tertiary uppercase">Описание</div>
<p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary"> <p className="mt-1 max-h-20 overflow-y-auto text-12 leading-5 text-secondary">
{parseResult.draft.description} {parseResult.draft.description}
</p> </p>
@ -463,10 +531,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)} )}
<div className="flex flex-wrap gap-1.5 text-11 text-secondary"> <div className="flex flex-wrap gap-1.5 text-11 text-secondary">
<span className="rounded bg-layer-1 px-2 py-1">intent {formatConfidence(parseResult.draft.confidence.intent)}</span> <span className="rounded bg-layer-1 px-2 py-1">
<span className="rounded bg-layer-1 px-2 py-1">project {formatConfidence(parseResult.draft.confidence.project)}</span> intent {formatConfidence(parseResult.draft.confidence.intent)}
<span className="rounded bg-layer-1 px-2 py-1">assignee {formatConfidence(parseResult.draft.confidence.assignee)}</span> </span>
<span className="rounded bg-layer-1 px-2 py-1">task {formatConfidence(parseResult.draft.confidence.task)}</span> <span className="rounded bg-layer-1 px-2 py-1">
project {formatConfidence(parseResult.draft.confidence.project)}
</span>
<span className="rounded bg-layer-1 px-2 py-1">
assignee {formatConfidence(parseResult.draft.confidence.assignee)}
</span>
<span className="rounded bg-layer-1 px-2 py-1">
task {formatConfidence(parseResult.draft.confidence.task)}
</span>
{parseResult.resolution?.project && ( {parseResult.resolution?.project && (
<span className="rounded bg-layer-1 px-2 py-1"> <span className="rounded bg-layer-1 px-2 py-1">
resolved project {formatConfidence(parseResult.resolution.project.confidence)} resolved project {formatConfidence(parseResult.resolution.project.confidence)}
@ -474,15 +550,15 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
)} )}
</div> </div>
{Boolean(parseResult.warnings?.length || parseResult.draft.questions.length) && ( {Boolean(getVoiceTaskWarnings(parseResult).length) && (
<div className="rounded border-[0.5px] border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-11 text-yellow-600"> <div className="border-yellow-500/30 bg-yellow-500/10 text-yellow-600 rounded border-[0.5px] px-3 py-2 text-11">
{[...(parseResult.warnings ?? []), ...parseResult.draft.questions].join(" · ")} {getVoiceTaskWarnings(parseResult).join(" · ")}
</div> </div>
)} )}
{commitResult?.task_key && ( {commitResult?.task_key && (
<div className="rounded border-[0.5px] border-green-500/30 bg-green-500/10 px-3 py-2 text-12 text-green-600"> <div className="border-green-500/30 bg-green-500/10 text-green-600 rounded border-[0.5px] px-3 py-2 text-12">
Создана задача {commitResult.task_key} {getCommitSuccessMessage(commitResult)}
</div> </div>
)} )}
</div> </div>
@ -505,26 +581,34 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
{isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />} {isRecording ? <Square className="mr-2 size-4" /> : <Mic className="mr-2 size-4" />}
{isRecording ? "Стоп" : "Записать"} {isRecording ? "Стоп" : "Записать"}
</Button> </Button>
<Button {!parseResult?.draft && (
variant="primary"
size="lg"
onClick={uploadAudio}
loading={isUploading}
disabled={!audioBlob || isRecording || isCommitting}
>
<Upload className="mr-2 size-4" />
Отправить
</Button>
{parseResult?.draft?.intent === "create_task" && !commitResult?.task_id && (
<Button <Button
variant="primary" variant="primary"
size="lg" size="lg"
onClick={uploadAudio}
loading={isUploading}
disabled={!audioBlob || isRecording || isCommitting}
>
<Upload className="mr-2 size-4" />
Отправить
</Button>
)}
{parseResult?.draft?.intent && parseResult.draft.intent !== "unknown" && !commitResult?.task_id && (
<Button
variant={parseResult.draft.intent === "delete_task" ? "error-fill" : "primary"}
size="lg"
onClick={commitVoiceTask} onClick={commitVoiceTask}
loading={isCommitting} loading={isCommitting}
disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading} disabled={!parseResult.voice_session_id || !parseResult.resolution?.can_commit || isUploading}
> >
<Plus className="mr-2 size-4" /> {parseResult.draft.intent === "update_task" ? (
Создать задачу <Pencil className="mr-2 size-4" />
) : parseResult.draft.intent === "delete_task" ? (
<Trash2 className="mr-2 size-4" />
) : (
<Plus className="mr-2 size-4" />
)}
{getCommitButtonLabel(parseResult.draft.intent)}
</Button> </Button>
)} )}
</div> </div>

View File

@ -29,10 +29,7 @@ export class WorkspaceAIService extends APIService {
}); });
} }
async updateSettings( async updateSettings(workspaceSlug: string, data: TWorkspaceAISettingsPayload): Promise<TWorkspaceAISettings> {
workspaceSlug: string,
data: TWorkspaceAISettingsPayload
): Promise<TWorkspaceAISettings> {
return this.patch(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/`, data) return this.patch(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/`, data)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -68,7 +65,7 @@ export class WorkspaceAIService extends APIService {
workspaceSlug: string, workspaceSlug: string,
data: { data: {
voice_session_id: string; voice_session_id: string;
action: "create_task"; action: "create_task" | "update_task" | "delete_task";
draft?: TVoiceTaskDraft; draft?: TVoiceTaskDraft;
} }
): Promise<TVoiceTaskCommitResult> { ): Promise<TVoiceTaskCommitResult> {

View File

@ -26,11 +26,29 @@ http {
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
location = /sw.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri =404;
}
location ~* ^/assets/.+\.(js|css|png|jpg|jpeg|gif|svg|webp|ico|woff|woff2)$ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }
} }

View File

@ -84,6 +84,7 @@ export type TVoiceTaskDraft = {
target_memory_ref: string | null; target_memory_ref: string | null;
project_id?: string | null; project_id?: string | null;
project_hint: string | null; project_hint: string | null;
state_hint: string | null;
assignee_hint: string | null; assignee_hint: string | null;
title: string | null; title: string | null;
description: string | null; description: string | null;
@ -102,13 +103,7 @@ export type TVoiceTaskDraft = {
}; };
export type TVoiceTaskResolution = { export type TVoiceTaskResolution = {
project: { project: TVoiceTaskResolvedProject | null;
id: string;
name: string;
identifier: string;
confidence: number;
source: string | null;
} | null;
assignee: { assignee: {
id: string; id: string;
name: string; name: string;
@ -120,10 +115,40 @@ export type TVoiceTaskResolution = {
id: string; id: string;
name: string; name: string;
}[]; }[];
state: {
id: string;
name: string;
group: string;
confidence: number;
source: string | null;
} | null;
target_task: {
id: string;
title: string;
key: string | null;
project_id: string;
project_name: string;
project_identifier: string;
sequence_id: number;
source: string | null;
voice_session_id: string | null;
} | null;
project_change: {
from: TVoiceTaskResolvedProject;
to: TVoiceTaskResolvedProject;
} | null;
warnings: string[]; warnings: string[];
can_commit: boolean; can_commit: boolean;
}; };
export type TVoiceTaskResolvedProject = {
id: string;
name: string;
identifier: string;
confidence: number;
source: string | null;
};
export type TVoiceTaskUploadResult = { export type TVoiceTaskUploadResult = {
ok: boolean; ok: boolean;
status?: "uploaded" | "parsed"; status?: "uploaded" | "parsed";
@ -151,7 +176,7 @@ export type TVoiceTaskUploadResult = {
export type TVoiceTaskCommitResult = { export type TVoiceTaskCommitResult = {
ok: boolean; ok: boolean;
status?: "created"; status?: "created" | "updated" | "deleted";
voice_session_id?: string; voice_session_id?: string;
task_id?: string; task_id?: string;
task_key?: string; task_key?: string;

View File

@ -78,6 +78,8 @@ export type TBaseIssue = {
is_draft: boolean; is_draft: boolean;
is_epic?: boolean; is_epic?: boolean;
is_intake?: boolean; is_intake?: boolean;
external_source?: string | null;
external_id?: string | null;
}; };
type IssueRelation = { type IssueRelation = {