ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: resolver, commit и обновление доски Voice Tasker
This commit is contained in:
@@ -244,6 +244,7 @@ from .webhook.base import (
|
||||
)
|
||||
|
||||
from .voice_tasker import (
|
||||
VoiceTaskCommitEndpoint,
|
||||
VoiceTaskParseEndpoint,
|
||||
VoiceTaskPreflightEndpoint,
|
||||
WorkspaceAISettingsEndpoint,
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
from html import escape
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.utils import timezone
|
||||
|
||||
from openai import OpenAI
|
||||
@@ -15,9 +18,15 @@ from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import WorkspaceAISettingsSerializer
|
||||
from plane.app.serializers import IssueCreateSerializer, WorkspaceAISettingsSerializer
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.issue_description_version_task import issue_description_version_task
|
||||
from plane.bgtasks.webhook_task import model_activity
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
Label,
|
||||
Project,
|
||||
ProjectMember,
|
||||
VoiceTaskSession,
|
||||
Workspace,
|
||||
WorkspaceAICredential,
|
||||
@@ -26,6 +35,7 @@ from plane.db.models import (
|
||||
)
|
||||
from plane.license.utils.encryption import decrypt_data
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.host import base_host
|
||||
|
||||
from .base import BaseAPIView
|
||||
|
||||
@@ -34,6 +44,8 @@ VOICE_TASK_INTENTS = {"create_task", "update_task", "delete_task", "unknown"}
|
||||
VOICE_TASK_PRIORITIES = {"none", "low", "medium", "high", "urgent"}
|
||||
VOICE_TASK_MEMORY_LIMIT = 5
|
||||
VOICE_TASK_CONTEXT_LIMIT = 100
|
||||
VOICE_TASK_PROJECT_MATCH_THRESHOLD = 0.8
|
||||
VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD = 0.8
|
||||
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
TIME_PATTERN = re.compile(r"^\d{2}:\d{2}$")
|
||||
|
||||
@@ -242,13 +254,20 @@ def get_client_language(client_context):
|
||||
return language if len(language) == 2 else None
|
||||
|
||||
|
||||
def serialize_workspace_projects(workspace, user):
|
||||
def get_accessible_projects(workspace, user):
|
||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first()
|
||||
projects = Project.objects.filter(workspace=workspace, archived_at__isnull=True)
|
||||
|
||||
if not workspace_member or workspace_member.role != ROLE.ADMIN.value:
|
||||
projects = projects.filter(project_projectmember__member=user, project_projectmember__is_active=True)
|
||||
if not workspace_member:
|
||||
return Project.objects.none()
|
||||
|
||||
projects = projects.filter(project_projectmember__member=user, project_projectmember__is_active=True)
|
||||
|
||||
return projects.distinct()
|
||||
|
||||
|
||||
def serialize_workspace_projects(workspace, user):
|
||||
projects = get_accessible_projects(workspace, user)
|
||||
return [
|
||||
{
|
||||
"id": str(project.id),
|
||||
@@ -259,6 +278,242 @@ def serialize_workspace_projects(workspace, user):
|
||||
]
|
||||
|
||||
|
||||
def normalize_match_value(value):
|
||||
normalized = normalize_string(value)
|
||||
if not normalized:
|
||||
return ""
|
||||
|
||||
normalized = normalized.lower()
|
||||
normalized = re.sub(r"\b(контур|проект|project|workspace|задача|таск)\b", " ", normalized)
|
||||
normalized = re.sub(r"[^0-9a-zа-яё]+", " ", normalized)
|
||||
return re.sub(r"\s+", " ", normalized).strip()
|
||||
|
||||
|
||||
def get_text_match_score(query, candidates):
|
||||
normalized_query = normalize_match_value(query)
|
||||
if not normalized_query:
|
||||
return 0.0
|
||||
|
||||
best_score = 0.0
|
||||
for candidate in candidates:
|
||||
normalized_candidate = normalize_match_value(candidate)
|
||||
if not normalized_candidate:
|
||||
continue
|
||||
if normalized_query == normalized_candidate:
|
||||
best_score = max(best_score, 1.0)
|
||||
elif normalized_query in normalized_candidate or normalized_candidate in normalized_query:
|
||||
best_score = max(best_score, 0.9)
|
||||
else:
|
||||
best_score = max(best_score, SequenceMatcher(None, normalized_query, normalized_candidate).ratio())
|
||||
|
||||
return round(best_score, 3)
|
||||
|
||||
|
||||
def serialize_resolved_project(project, confidence=0.0, source=None):
|
||||
if not project:
|
||||
return None
|
||||
return {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
"confidence": confidence,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def serialize_resolved_assignee(user, confidence=0.0, source=None):
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"name": user.display_name or user.email or "",
|
||||
"email": user.email,
|
||||
"confidence": confidence,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
|
||||
def resolve_voice_task_project(workspace, user, ai_settings, draft, client_context):
|
||||
projects = list(get_accessible_projects(workspace, user).order_by("name"))
|
||||
if not projects:
|
||||
return None
|
||||
|
||||
project_by_id = {str(project.id): project for project in projects}
|
||||
explicit_project_id = normalize_string(draft.get("project_id"))
|
||||
if explicit_project_id and explicit_project_id in project_by_id:
|
||||
return serialize_resolved_project(project_by_id[explicit_project_id], 1.0, "explicit_project_id")
|
||||
|
||||
project_hint = draft.get("project_hint")
|
||||
if project_hint:
|
||||
best_project = None
|
||||
best_score = 0.0
|
||||
for project in projects:
|
||||
score = get_text_match_score(project_hint, [project.name, project.identifier])
|
||||
if score > best_score:
|
||||
best_project = project
|
||||
best_score = score
|
||||
if best_project and best_score >= VOICE_TASK_PROJECT_MATCH_THRESHOLD:
|
||||
return serialize_resolved_project(best_project, best_score, "project_hint")
|
||||
|
||||
current_project_id = normalize_string(client_context.get("current_project_id"))
|
||||
if current_project_id and current_project_id in project_by_id:
|
||||
return serialize_resolved_project(project_by_id[current_project_id], 0.7, "current_project")
|
||||
|
||||
if ai_settings.default_project_id and str(ai_settings.default_project_id) in project_by_id:
|
||||
return serialize_resolved_project(project_by_id[str(ai_settings.default_project_id)], 0.65, "default_project")
|
||||
|
||||
if project_hint and best_project:
|
||||
return serialize_resolved_project(best_project, best_score, "low_confidence_project_hint")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_voice_task_assignee(project, draft):
|
||||
assignee_hint = draft.get("assignee_hint")
|
||||
if not assignee_hint:
|
||||
return None
|
||||
|
||||
project_members = (
|
||||
ProjectMember.objects.filter(project=project, is_active=True, role__gte=ROLE.MEMBER.value, member__is_active=True)
|
||||
.select_related("member")
|
||||
.order_by("member__display_name", "member__email")
|
||||
)
|
||||
best_member = None
|
||||
best_score = 0.0
|
||||
for project_member in project_members:
|
||||
member = project_member.member
|
||||
score = get_text_match_score(
|
||||
assignee_hint,
|
||||
[
|
||||
member.display_name,
|
||||
member.first_name,
|
||||
member.last_name,
|
||||
f"{member.first_name} {member.last_name}".strip(),
|
||||
member.email,
|
||||
],
|
||||
)
|
||||
if score > best_score:
|
||||
best_member = member
|
||||
best_score = score
|
||||
|
||||
if not best_member:
|
||||
return None
|
||||
|
||||
return serialize_resolved_assignee(best_member, best_score, "assignee_hint")
|
||||
|
||||
|
||||
def resolve_voice_task_labels(project, draft):
|
||||
label_names = draft.get("labels") if isinstance(draft.get("labels"), list) else []
|
||||
if not label_names:
|
||||
return []
|
||||
|
||||
labels = Label.objects.filter(project=project)
|
||||
resolved_labels = []
|
||||
for label_name in label_names:
|
||||
normalized_label_name = normalize_match_value(label_name)
|
||||
if not normalized_label_name:
|
||||
continue
|
||||
label = next((label for label in labels if normalize_match_value(label.name) == normalized_label_name), None)
|
||||
if label:
|
||||
resolved_labels.append({"id": str(label.id), "name": label.name})
|
||||
return resolved_labels
|
||||
|
||||
|
||||
def can_user_create_issue_in_project(user, workspace, project):
|
||||
project_member = ProjectMember.objects.filter(project=project, member=user, is_active=True).first()
|
||||
if not project_member:
|
||||
return False
|
||||
|
||||
if project_member.role in [ROLE.ADMIN.value, ROLE.MEMBER.value]:
|
||||
return True
|
||||
|
||||
return WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
member=user,
|
||||
role=ROLE.ADMIN.value,
|
||||
is_active=True,
|
||||
).exists()
|
||||
|
||||
|
||||
def build_voice_task_resolution(workspace, user, ai_settings, draft, client_context):
|
||||
warnings = []
|
||||
resolved_project = resolve_voice_task_project(workspace, user, ai_settings, draft, client_context)
|
||||
project = Project.objects.filter(id=resolved_project["id"], workspace=workspace).first() if resolved_project else None
|
||||
|
||||
if not project or not resolved_project:
|
||||
warnings.append("project_not_resolved")
|
||||
elif resolved_project["confidence"] < VOICE_TASK_PROJECT_MATCH_THRESHOLD and resolved_project["source"] == "low_confidence_project_hint":
|
||||
warnings.append("low_project_confidence")
|
||||
|
||||
resolved_assignee = None
|
||||
resolved_labels = []
|
||||
if project:
|
||||
resolved_assignee = resolve_voice_task_assignee(project, draft)
|
||||
if resolved_assignee and resolved_assignee["confidence"] < VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
warnings.append("low_assignee_confidence")
|
||||
resolved_labels = resolve_voice_task_labels(project, draft)
|
||||
|
||||
if not can_user_create_issue_in_project(user, workspace, project):
|
||||
warnings.append("project_permission_denied")
|
||||
|
||||
if draft.get("intent") != "create_task":
|
||||
warnings.append("unsupported_intent")
|
||||
if not draft.get("title"):
|
||||
warnings.append("missing_title")
|
||||
|
||||
can_commit = bool(
|
||||
project
|
||||
and draft.get("intent") == "create_task"
|
||||
and draft.get("title")
|
||||
and "project_permission_denied" not in warnings
|
||||
and "low_project_confidence" not in warnings
|
||||
)
|
||||
|
||||
return {
|
||||
"project": resolved_project,
|
||||
"assignee": resolved_assignee,
|
||||
"labels": resolved_labels,
|
||||
"warnings": warnings,
|
||||
"can_commit": can_commit,
|
||||
}
|
||||
|
||||
|
||||
def build_voice_task_description_html(draft):
|
||||
parts = []
|
||||
if draft.get("description"):
|
||||
parts.append(f"<p>{escape(draft['description'])}</p>")
|
||||
|
||||
if draft.get("due_time"):
|
||||
parts.append(f"<p><strong>Ориентир по времени:</strong> до {escape(draft['due_time'])}</p>")
|
||||
|
||||
checklist = draft.get("checklist") if isinstance(draft.get("checklist"), list) else []
|
||||
if checklist:
|
||||
items = "".join(f"<li>{escape(item)}</li>" for item in checklist if item)
|
||||
if items:
|
||||
parts.append(f"<p><strong>Checklist:</strong></p><ul>{items}</ul>")
|
||||
|
||||
return "".join(parts) or "<p></p>"
|
||||
|
||||
|
||||
def build_voice_task_issue_payload(draft, resolution):
|
||||
project = resolution.get("project")
|
||||
assignee = resolution.get("assignee")
|
||||
labels = resolution.get("labels") or []
|
||||
assignee_ids = []
|
||||
if assignee and assignee["confidence"] >= VOICE_TASK_ASSIGNEE_MATCH_THRESHOLD:
|
||||
assignee_ids = [assignee["id"]]
|
||||
|
||||
return {
|
||||
"name": draft["title"],
|
||||
"description_html": build_voice_task_description_html(draft),
|
||||
"target_date": draft.get("due_date"),
|
||||
"priority": draft.get("priority") or "none",
|
||||
"assignee_ids": assignee_ids,
|
||||
"label_ids": [label["id"] for label in labels],
|
||||
"project_id": project["id"] if project else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_workspace_members(workspace):
|
||||
members = WorkspaceMember.objects.filter(
|
||||
workspace=workspace,
|
||||
@@ -641,6 +896,14 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||
model=ai_settings.structuring_model,
|
||||
).parse(parser_context)
|
||||
warnings = get_voice_task_warnings(parsed, transcript)
|
||||
resolution = build_voice_task_resolution(
|
||||
workspace=workspace,
|
||||
user=request.user,
|
||||
ai_settings=ai_settings,
|
||||
draft=parsed,
|
||||
client_context=client_context,
|
||||
)
|
||||
warnings = list(dict.fromkeys(warnings + resolution["warnings"]))
|
||||
requires_confirmation = voice_task_requires_confirmation(parsed, warnings)
|
||||
|
||||
voice_session.status = VoiceTaskSession.Status.PARSED
|
||||
@@ -657,6 +920,7 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||
"transcript": transcript,
|
||||
"intent": parsed["intent"],
|
||||
"draft": parsed,
|
||||
"resolution": resolution,
|
||||
"warnings": warnings,
|
||||
"requires_confirmation": requires_confirmation,
|
||||
"models": {
|
||||
@@ -691,3 +955,151 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||
},
|
||||
status=pipeline_error.response_status,
|
||||
)
|
||||
|
||||
|
||||
class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
preflight = get_voice_task_preflight(workspace, request.user)
|
||||
if not preflight["available"]:
|
||||
response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": preflight["reason"],
|
||||
"error": "Voice Tasker is not available for this workspace.",
|
||||
},
|
||||
status=response_status,
|
||||
)
|
||||
|
||||
voice_session_id = request.data.get("voice_session_id")
|
||||
if not voice_session_id:
|
||||
return Response(
|
||||
{"ok": False, "code": "missing_voice_session_id", "error": "Voice session id is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
voice_session = VoiceTaskSession.objects.filter(
|
||||
id=voice_session_id,
|
||||
workspace=workspace,
|
||||
user=request.user,
|
||||
).first()
|
||||
if not voice_session:
|
||||
return Response(
|
||||
{"ok": False, "code": "voice_session_not_found", "error": "Voice session was not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
if voice_session.status != VoiceTaskSession.Status.PARSED or not voice_session.parsed_json:
|
||||
return Response(
|
||||
{"ok": False, "code": "voice_session_not_parsed", "error": "Voice session is not ready to commit."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
draft = normalize_voice_task_parse(request.data.get("draft") or voice_session.parsed_json)
|
||||
except VoiceTaskerPipelineError as exc:
|
||||
return Response(
|
||||
{"ok": False, "code": exc.code, "error": exc.message},
|
||||
status=exc.response_status,
|
||||
)
|
||||
|
||||
action = request.data.get("action") or draft["intent"]
|
||||
if action != "create_task" or draft["intent"] != "create_task":
|
||||
return Response(
|
||||
{"ok": False, "code": "unsupported_intent", "error": "Only create_task commit is supported now."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
|
||||
resolution = build_voice_task_resolution(
|
||||
workspace=workspace,
|
||||
user=request.user,
|
||||
ai_settings=ai_settings,
|
||||
draft=draft,
|
||||
client_context=voice_session.client_context or {},
|
||||
)
|
||||
if not resolution["can_commit"]:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "draft_not_resolved",
|
||||
"error": "Voice Task draft is not resolved enough to create a work item.",
|
||||
"resolution": resolution,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
project = Project.objects.get(id=resolution["project"]["id"], workspace=workspace)
|
||||
payload = build_voice_task_issue_payload(draft, resolution)
|
||||
payload_without_project = {key: value for key, value in payload.items() if key != "project_id"}
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=payload_without_project,
|
||||
context={
|
||||
"project_id": project.id,
|
||||
"workspace_id": workspace.id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "issue_validation_failed",
|
||||
"error": "Voice Task draft could not be converted to a work item.",
|
||||
"details": serializer.errors,
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
issue = serializer.save(created_by_id=request.user.id)
|
||||
voice_session.created_task = issue
|
||||
voice_session.parsed_json = draft
|
||||
voice_session.save(update_fields=["created_task", "parsed_json", "updated_at"])
|
||||
|
||||
requested_data = json.dumps(payload_without_project, cls=DjangoJSONEncoder)
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=requested_data,
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
project_id=str(project.id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
model_activity.delay(
|
||||
model_name="issue",
|
||||
model_id=str(issue.id),
|
||||
requested_data=payload_without_project,
|
||||
current_instance=None,
|
||||
actor_id=request.user.id,
|
||||
slug=slug,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_description_version_task.delay(
|
||||
updated_issue=requested_data,
|
||||
issue_id=str(issue.id),
|
||||
user_id=request.user.id,
|
||||
is_creating=True,
|
||||
)
|
||||
|
||||
issue_key = f"{project.identifier}-{issue.sequence_id}"
|
||||
task_url = f"/{slug}/browse/{issue_key}/"
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"status": "created",
|
||||
"voice_session_id": str(voice_session.id),
|
||||
"task_id": str(issue.id),
|
||||
"task_key": issue_key,
|
||||
"task_url": task_url,
|
||||
"project_id": str(project.id),
|
||||
"sequence_id": issue.sequence_id,
|
||||
"resolution": resolution,
|
||||
},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user