ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: hardening Voice Tasker routing, сроков и transcript
This commit is contained in:
parent
d3b47326da
commit
597480adb9
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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} ->{" "}
|
||||||
|
{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>
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue