diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/ai/OllamaService.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/ai/OllamaService.java index a5b6b9b..43b1843 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/ai/OllamaService.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/ai/OllamaService.java @@ -36,30 +36,30 @@ public class OllamaService { .connectTimeout(Duration.ofSeconds(10)) .build(); - public String generateSummary(String systemPrompt, String tasksContext) { - if (tasksContext == null || tasksContext.isBlank()) { - return "Nenhuma atividade registrada no período para resumo."; + public String chat(String systemPrompt, String userPrompt, boolean jsonFormat) { + if (userPrompt == null || userPrompt.isBlank()) { + return null; } - String effectiveSystemPrompt = (systemPrompt != null && !systemPrompt.isBlank()) - ? systemPrompt - : "Você é um assistente corporativo. Com base na lista de tarefas realizadas pelo colaborador abaixo, elabore um resumo conciso e profissional em um ou dois parágrafos destacando as principais entregas."; - try { Map payload = new HashMap<>(); payload.put("model", model); payload.put("stream", false); payload.put("think", false); + if (jsonFormat) { + payload.put("format", "json"); + } - List> messages = List.of( - Map.of("role", "system", "content", effectiveSystemPrompt), - Map.of("role", "user", "content", tasksContext) - ); + List> messages = new java.util.ArrayList<>(); + if (systemPrompt != null && !systemPrompt.isBlank()) { + messages.add(Map.of("role", "system", "content", systemPrompt)); + } + messages.add(Map.of("role", "user", "content", userPrompt)); payload.put("messages", messages); Map options = Map.of( - "num_ctx", 8192, - "temperature", 0.3 + "num_ctx", 4096, + "temperature", 0.2 ); payload.put("options", options); @@ -73,7 +73,7 @@ public class OllamaService { .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); - LOGGER.infof("Enviando requisição de resumo para Ollama (%s) no modelo %s...", url, model); + LOGGER.infof("Enviando requisição para Ollama (%s) no modelo %s...", url, model); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 200) { @@ -86,15 +86,28 @@ public class OllamaService { sanitized = sanitized.substring(sanitized.indexOf("\n\n") + 2).trim(); } - LOGGER.infof("Resumo com IA gerado com sucesso pelo Ollama (%d caracteres)", sanitized.length()); + LOGGER.infof("Resposta da IA gerada com sucesso pelo Ollama (%d caracteres)", sanitized.length()); return sanitized; } else { LOGGER.warnf("Ollama retornou status HTTP %d: %s", response.statusCode(), response.body()); return null; } } catch (Exception e) { - LOGGER.warnf("Não foi possível conectar ao Ollama para gerar resumo com IA: %s", e.getMessage()); + LOGGER.warnf("Erro ao conectar ou processar requisição no Ollama: %s", e.getMessage()); return null; } } + + public String generateSummary(String systemPrompt, String tasksContext) { + if (tasksContext == null || tasksContext.isBlank()) { + return "Nenhuma atividade registrada no período para resumo."; + } + + String effectiveSystemPrompt = (systemPrompt != null && !systemPrompt.isBlank()) + ? systemPrompt + : "Você é um assistente corporativo. Com base na lista de tarefas realizadas pelo colaborador abaixo, elabore um resumo conciso e profissional em um ou dois parágrafos destacando as principais entregas."; + + String result = chat(effectiveSystemPrompt, tasksContext, false); + return result != null ? result : "Não foi possível gerar o resumo com IA neste momento."; + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java index 5d3f66c..83d9461 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java @@ -15,6 +15,10 @@ import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase; import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase; +import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionUseCase; +import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiUseCase; +import net.beetechgroup.beetask.frameworks.ai.OllamaService; +import com.fasterxml.jackson.databind.ObjectMapper; import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase; @ApplicationScoped @@ -73,5 +77,25 @@ public class TaskUseCaseConfig { UserOrganizationRepository userOrganizationRepository) { return new net.beetechgroup.beetask.usecase.task.delete.DeleteTaskUseCase(taskRepository, userRepository, userOrganizationRepository); } + + @Produces + public ParseTaskWithAiUseCase parseTaskWithAiUseCase( + OllamaService ollamaService, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationMemberUseCase authorizer, + ObjectMapper objectMapper) { + return new ParseTaskWithAiUseCase(ollamaService, projectRepository, categoryRepository, userOrganizationRepository, authorizer, objectMapper); + } + + @Produces + public EnrichTaskDescriptionUseCase enrichTaskDescriptionUseCase( + OllamaService ollamaService, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new EnrichTaskDescriptionUseCase(ollamaService, projectRepository, categoryRepository, authorizer); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionRequest.java new file mode 100644 index 0000000..cec8145 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionRequest.java @@ -0,0 +1,9 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.task; + +public record EnrichTaskDescriptionRequest( + Long organizationId, + String title, + String currentDescription, + Long projectId, + Long categoryId +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionResponse.java new file mode 100644 index 0000000..0899e44 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/EnrichTaskDescriptionResponse.java @@ -0,0 +1,5 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.task; + +public record EnrichTaskDescriptionResponse( + String description +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParseTaskWithAiRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParseTaskWithAiRequest.java new file mode 100644 index 0000000..6c9b884 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParseTaskWithAiRequest.java @@ -0,0 +1,6 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.task; + +public record ParseTaskWithAiRequest( + Long organizationId, + String prompt +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParsedTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParsedTaskResponse.java new file mode 100644 index 0000000..51cee3d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ParsedTaskResponse.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.task; + +public record ParsedTaskResponse( + String title, + String description, + Long projectId, + Long categoryId, + Long assigneeUserId, + String status +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java index 23e109f..4e08b4d 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java @@ -35,6 +35,12 @@ import net.beetechgroup.beetask.usecase.task.start.StartTaskUseCase; import net.beetechgroup.beetask.usecase.task.stop.StopTaskUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskStatusUseCase; import net.beetechgroup.beetask.usecase.task.update.UpdateTaskUseCase; +import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionInput; +import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionOutput; +import net.beetechgroup.beetask.usecase.task.ai.EnrichTaskDescriptionUseCase; +import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiInput; +import net.beetechgroup.beetask.usecase.task.ai.ParsedTaskOutput; +import net.beetechgroup.beetask.usecase.task.ai.ParseTaskWithAiUseCase; import net.beetechgroup.beetask.usecase.task.get.GetTaskUseCase; @Path("/tasks") @@ -72,6 +78,12 @@ public class TaskController { @Inject DeleteTaskUseCase deleteTaskUseCase; + @Inject + ParseTaskWithAiUseCase parseTaskWithAiUseCase; + + @Inject + EnrichTaskDescriptionUseCase enrichTaskDescriptionUseCase; + @Inject SecurityIdentity securityIdentity; @@ -179,4 +191,44 @@ public class TaskController { return tasks; } + @POST + @Path("/ai/parse") + @Operation(summary = "Parse natural language task", description = "Extracts task details from natural language using AI") + @APIResponse(responseCode = "200", description = "Task parsed successfully") + public ParsedTaskResponse parseTaskWithAi(ParseTaskWithAiRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("User %s requested AI task parsing for org %d", email, request.organizationId()); + ParsedTaskOutput output = parseTaskWithAiUseCase.execute( + new ParseTaskWithAiInput(email, request.organizationId(), request.prompt()) + ); + return new ParsedTaskResponse( + output.title(), + output.description(), + output.projectId(), + output.categoryId(), + output.assigneeUserId(), + output.status() + ); + } + + @POST + @Path("/ai/enrich") + @Operation(summary = "Enrich task description", description = "Generates structured description and acceptance criteria using AI") + @APIResponse(responseCode = "200", description = "Task description enriched successfully") + public EnrichTaskDescriptionResponse enrichTaskDescription(EnrichTaskDescriptionRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("User %s requested AI description enrichment for task '%s'", email, request.title()); + EnrichTaskDescriptionOutput output = enrichTaskDescriptionUseCase.execute( + new EnrichTaskDescriptionInput( + email, + request.organizationId(), + request.title(), + request.currentDescription(), + request.projectId(), + request.categoryId() + ) + ); + return new EnrichTaskDescriptionResponse(output.description()); + } + } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionInput.java new file mode 100644 index 0000000..3801226 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionInput.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +public record EnrichTaskDescriptionInput( + String userEmail, + Long organizationId, + String title, + String currentDescription, + Long projectId, + Long categoryId +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionOutput.java new file mode 100644 index 0000000..5dbcd70 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionOutput.java @@ -0,0 +1,5 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +public record EnrichTaskDescriptionOutput( + String description +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionUseCase.java new file mode 100644 index 0000000..be5a5ac --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/EnrichTaskDescriptionUseCase.java @@ -0,0 +1,99 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +import net.beetechgroup.beetask.entities.Category; +import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.frameworks.ai.OllamaService; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.CategoryRepository; +import net.beetechgroup.beetask.usecase.repository.ProjectRepository; +import org.jboss.logging.Logger; + +public class EnrichTaskDescriptionUseCase { + private static final Logger LOGGER = Logger.getLogger(EnrichTaskDescriptionUseCase.class); + + private final OllamaService ollamaService; + private final ProjectRepository projectRepository; + private final CategoryRepository categoryRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public EnrichTaskDescriptionUseCase( + OllamaService ollamaService, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + AuthorizeOrganizationMemberUseCase authorizer + ) { + this.ollamaService = ollamaService; + this.projectRepository = projectRepository; + this.categoryRepository = categoryRepository; + this.authorizer = authorizer; + } + + public EnrichTaskDescriptionOutput execute(EnrichTaskDescriptionInput input) { + if (input.title() == null || input.title().isBlank()) { + throw new IllegalArgumentException("O título da tarefa é obrigatório para gerar ou enriquecer a descrição."); + } + + authorizer.execute(input.userEmail(), input.organizationId()); + + String projectName = null; + if (input.projectId() != null) { + projectName = projectRepository.findProjectById(input.projectId()) + .map(Project::getName) + .orElse(null); + } + + String categoryName = null; + if (input.categoryId() != null) { + categoryName = categoryRepository.findCategoryById(input.categoryId()) + .map(Category::getName) + .orElse(null); + } + + StringBuilder systemPrompt = new StringBuilder(); + systemPrompt.append("Você é um especialista em engenharia de software e gestão ágil de projetos.\n"); + systemPrompt.append("Sua missão é gerar uma descrição detalhada, clara e profissional em formato Markdown para uma tarefa de trabalho.\n\n"); + systemPrompt.append("Estruture a descrição OBRIGATORIAMENTE no seguinte padrão:\n"); + systemPrompt.append("### Contexto e Objetivo\n"); + systemPrompt.append("(Explique de forma objetiva o que motivou a tarefa e a meta principal)\n\n"); + systemPrompt.append("### Escopo de Execução\n"); + systemPrompt.append("(Detalhe os passos e o que precisa ser desenvolvido ou executado)\n\n"); + systemPrompt.append("### Critérios de Aceitação\n"); + systemPrompt.append("- [ ] Critério 1 verificável\n"); + systemPrompt.append("- [ ] Critério 2 verificável\n"); + systemPrompt.append("- [ ] Critério 3 verificável\n\n"); + systemPrompt.append("IMPORTANTE:\n"); + systemPrompt.append("- Responda APENAS com o texto Markdown da descrição. Não inclua saudações, introduções ('Aqui está...') ou blocos de código delimitadores ```markdown no início ou fim.\n"); + systemPrompt.append("- Use tom profissional e direto em português brasileiro."); + + StringBuilder userPrompt = new StringBuilder(); + userPrompt.append(String.format("Título da Tarefa: %s\n", input.title().trim())); + if (projectName != null) { + userPrompt.append(String.format("Projeto: %s\n", projectName)); + } + if (categoryName != null) { + userPrompt.append(String.format("Categoria: %s\n", categoryName)); + } + if (input.currentDescription() != null && !input.currentDescription().isBlank()) { + userPrompt.append(String.format("\nAnotações / Rascunho inicial do usuário:\n%s\n", input.currentDescription().trim())); + } + + String enriched = ollamaService != null + ? ollamaService.chat(systemPrompt.toString(), userPrompt.toString(), false) + : null; + + if (enriched == null || enriched.isBlank()) { + String fallback = input.currentDescription() != null && !input.currentDescription().isBlank() + ? input.currentDescription() + : "Não foi possível enriquecer a descrição com a IA no momento."; + return new EnrichTaskDescriptionOutput(fallback); + } + + // Limpeza defensiva caso o modelo coloque ```markdown no início ou fim + String cleaned = enriched.replaceAll("^```markdown\\s*", "") + .replaceAll("^```\\s*", "") + .replaceAll("\\s*```$", "") + .trim(); + + return new EnrichTaskDescriptionOutput(cleaned); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiInput.java new file mode 100644 index 0000000..c44d820 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiInput.java @@ -0,0 +1,7 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +public record ParseTaskWithAiInput( + String userEmail, + Long organizationId, + String prompt +) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiUseCase.java new file mode 100644 index 0000000..e175315 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParseTaskWithAiUseCase.java @@ -0,0 +1,175 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.beetechgroup.beetask.entities.Category; +import net.beetechgroup.beetask.entities.Project; +import net.beetechgroup.beetask.entities.organization.UserOrganization; +import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; +import net.beetechgroup.beetask.frameworks.ai.OllamaService; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.CategoryRepository; +import net.beetechgroup.beetask.usecase.repository.ProjectRepository; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import org.jboss.logging.Logger; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public class ParseTaskWithAiUseCase { + private static final Logger LOGGER = Logger.getLogger(ParseTaskWithAiUseCase.class); + + private final OllamaService ollamaService; + private final ProjectRepository projectRepository; + private final CategoryRepository categoryRepository; + private final UserOrganizationRepository userOrganizationRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + private final ObjectMapper objectMapper; + + public ParseTaskWithAiUseCase( + OllamaService ollamaService, + ProjectRepository projectRepository, + CategoryRepository categoryRepository, + UserOrganizationRepository userOrganizationRepository, + AuthorizeOrganizationMemberUseCase authorizer, + ObjectMapper objectMapper + ) { + this.ollamaService = ollamaService; + this.projectRepository = projectRepository; + this.categoryRepository = categoryRepository; + this.userOrganizationRepository = userOrganizationRepository; + this.authorizer = authorizer; + this.objectMapper = objectMapper; + } + + public ParsedTaskOutput execute(ParseTaskWithAiInput input) { + if (input.prompt() == null || input.prompt().isBlank()) { + throw new IllegalArgumentException("O prompt da tarefa não pode estar vazio."); + } + + authorizer.execute(input.userEmail(), input.organizationId()); + + List projects = input.organizationId() != null + ? projectRepository.findByOrganizationId(input.organizationId()) + : List.of(); + List categories = input.organizationId() != null + ? categoryRepository.findByOrganizationId(input.organizationId()) + : List.of(); + List members = input.organizationId() != null + ? userOrganizationRepository.findByOrganizationIdAndStatus(input.organizationId(), UserOrganizationStatus.ACTIVE) + : List.of(); + + Set validProjectIds = projects.stream().map(Project::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set validCategoryIds = categories.stream().map(Category::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set validMemberUserIds = members.stream() + .filter(m -> m.getUser() != null && m.getUser().getId() != null) + .map(m -> m.getUser().getId()) + .collect(Collectors.toSet()); + + StringBuilder promptBuilder = new StringBuilder(); + promptBuilder.append("Você é o assistente inteligente do BeeTask (sistema de gestão de tarefas corporativas).\n"); + promptBuilder.append("Sua função é interpretar a instrução em linguagem natural e extrair dados estruturados para preencher uma nova tarefa.\n\n"); + promptBuilder.append("Você DEVE responder EXCLUSIVAMENTE um objeto JSON válido no seguinte formato:\n"); + promptBuilder.append("{\n"); + promptBuilder.append(" \"title\": \"Título limpo, claro e conciso da tarefa (máximo 100 caracteres)\",\n"); + promptBuilder.append(" \"description\": \"Descrição detalhada com escopo e checklist de critérios de aceitação se aplicável\",\n"); + promptBuilder.append(" \"projectId\": ,\n"); + promptBuilder.append(" \"categoryId\": ,\n"); + promptBuilder.append(" \"assigneeUserId\": \n"); + promptBuilder.append("}\n\n"); + + promptBuilder.append("Contexto disponível na organização:\n"); + promptBuilder.append("Projetos:\n"); + if (projects.isEmpty()) { + promptBuilder.append(" (Nenhum projeto cadastrado)\n"); + } else { + for (Project p : projects) { + promptBuilder.append(String.format(" - ID %d: %s\n", p.getId(), p.getName())); + } + } + + promptBuilder.append("Categorias:\n"); + if (categories.isEmpty()) { + promptBuilder.append(" (Nenhuma categoria cadastrada)\n"); + } else { + for (Category c : categories) { + promptBuilder.append(String.format(" - ID %d: %s\n", c.getId(), c.getName())); + } + } + + promptBuilder.append("Membros disponíveis:\n"); + if (members.isEmpty()) { + promptBuilder.append(" (Nenhum membro cadastrado)\n"); + } else { + for (UserOrganization m : members) { + if (m.getUser() != null) { + promptBuilder.append(String.format(" - ID %d: %s (%s)\n", + m.getUser().getId(), + m.getUser().getName() != null ? m.getUser().getName() : "Sem nome", + m.getUser().getEmail() != null ? m.getUser().getEmail() : "")); + } + } + } + + promptBuilder.append("\nRegras mandatórias:\n"); + promptBuilder.append("1. Apenas retorne IDs numéricos que estejam explicitamente listados acima. Caso não encontre correspondência direta, use null.\n"); + promptBuilder.append("2. Se a instrução do usuário contiver itens ou passos, formate a descrição com marcadores Markdown ou critérios de aceite.\n"); + promptBuilder.append("3. Não inclua texto antes ou depois do JSON."); + + String rawJson = ollamaService != null + ? ollamaService.chat(promptBuilder.toString(), input.prompt(), true) + : null; + + if (rawJson != null && !rawJson.isBlank()) { + try { + JsonNode node = objectMapper.readTree(rawJson); + String title = node.path("title").asText("").trim(); + if (title.isBlank()) { + title = input.prompt().trim(); + } + String description = node.path("description").asText("").trim(); + + Long projectId = node.hasNonNull("projectId") ? node.path("projectId").asLong() : null; + if (projectId != null && !validProjectIds.contains(projectId)) { + LOGGER.infof("AI sugeriu projectId %d inválido para a org, descartando...", projectId); + projectId = null; + } + + Long categoryId = node.hasNonNull("categoryId") ? node.path("categoryId").asLong() : null; + if (categoryId != null && !validCategoryIds.contains(categoryId)) { + LOGGER.infof("AI sugeriu categoryId %d inválido para a org, descartando...", categoryId); + categoryId = null; + } + + Long assigneeUserId = node.hasNonNull("assigneeUserId") ? node.path("assigneeUserId").asLong() : null; + if (assigneeUserId != null && !validMemberUserIds.contains(assigneeUserId)) { + LOGGER.infof("AI sugeriu assigneeUserId %d inválido para a org, descartando...", assigneeUserId); + assigneeUserId = null; + } + + return new ParsedTaskOutput( + title, + description, + projectId, + categoryId, + assigneeUserId, + "NOT_STARTED" + ); + } catch (Exception e) { + LOGGER.warnf("Falha ao analisar JSON retornado pelo Ollama (%s): %s", rawJson, e.getMessage()); + } + } + + // Fallback defensivo: se a IA estiver offline ou não retornar JSON válido + return new ParsedTaskOutput( + input.prompt().trim(), + "", + null, + null, + null, + "NOT_STARTED" + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParsedTaskOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParsedTaskOutput.java new file mode 100644 index 0000000..5699a38 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/ai/ParsedTaskOutput.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.usecase.task.ai; + +public record ParsedTaskOutput( + String title, + String description, + Long projectId, + Long categoryId, + Long assigneeUserId, + String status +) {} diff --git a/frontend/src/components/tasks/CreateTaskModal.tsx b/frontend/src/components/tasks/CreateTaskModal.tsx index d6ac108..d06110b 100644 --- a/frontend/src/components/tasks/CreateTaskModal.tsx +++ b/frontend/src/components/tasks/CreateTaskModal.tsx @@ -15,12 +15,14 @@ import { FileText, AlertCircle, ExternalLink, + Wand2, } from 'lucide-react' -import type { Task, TaskStatus, Project, Category, TaskHistoryItem, TaskHistoryItemInput } from '../../types/task' +import type { Task, TaskStatus, Project, Category, TaskHistoryItem, TaskHistoryItemInput, ParsedAiTask } from '../../types/task' import type { OrganizationMember } from '../../services/organizationService' import { projectService } from '../../services/projectService' import { categoryService } from '../../services/categoryService' import { organizationService } from '../../services/organizationService' +import { taskService } from '../../services/taskService' import { CustomSelect } from '../ui/Select' import { useAuth } from '../../contexts/AuthContext' import { canDeleteTask } from '../../lib/taskPermissions' @@ -52,6 +54,7 @@ interface CreateTaskModalProps { initialStatus?: TaskStatus initialTask?: Task | null initialTab?: 'details' | 'history' + initialDraft?: Partial | null } export const CreateTaskModal: React.FC = ({ @@ -63,6 +66,7 @@ export const CreateTaskModal: React.FC = ({ initialStatus = 'NOT_STARTED', initialTask = null, initialTab = 'details', + initialDraft = null, }) => { const { user, activeOrg } = useAuth() const [activeTab, setActiveTab] = useState<'details' | 'history'>('details') @@ -103,6 +107,57 @@ export const CreateTaskModal: React.FC = ({ const [newCategoryName, setNewCategoryName] = useState('') const [showAddCategory, setShowAddCategory] = useState(false) + // AI Helper states + const [aiQuickPrompt, setAiQuickPrompt] = useState('') + const [isParsingInline, setIsParsingInline] = useState(false) + const [isEnriching, setIsEnriching] = useState(false) + const [enrichMessage, setEnrichMessage] = useState(null) + + const handleInlineAiParse = async () => { + if (!aiQuickPrompt.trim() || isParsingInline) return + setIsParsingInline(true) + setError(null) + try { + const parsed = await taskService.parseTaskWithAi(organizationId, aiQuickPrompt.trim()) + if (parsed.title) setTitle(parsed.title) + if (parsed.description) setDescription(parsed.description) + if (parsed.projectId !== undefined) setProjectId(parsed.projectId) + if (parsed.categoryId !== undefined) setCategoryId(parsed.categoryId) + if (parsed.assigneeUserId !== undefined) setAssigneeUserId(parsed.assigneeUserId) + setAiQuickPrompt('') + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Falha ao processar com IA.' + setError(msg) + } finally { + setIsParsingInline(false) + } + } + + const handleEnrichDescription = async () => { + if (!title.trim() || isEnriching) return + setIsEnriching(true) + setEnrichMessage(null) + try { + const res = await taskService.enrichTaskDescription({ + organizationId, + title: title.trim(), + currentDescription: description.trim(), + projectId, + categoryId, + }) + if (res?.description) { + setDescription(res.description) + setEnrichMessage('Descrição estruturada com IA com sucesso!') + setTimeout(() => setEnrichMessage(null), 3500) + } + } catch (err: unknown) { + console.error('Erro ao enriquecer descrição:', err) + setError('Não foi possível gerar a descrição com IA no momento.') + } finally { + setIsEnriching(false) + } + } + // Populate data when modal opens useEffect(() => { if (!isOpen) return @@ -117,6 +172,16 @@ export const CreateTaskModal: React.FC = ({ setExternalUrl(initialTask.externalUrl || '') setLocalHistory(initialTask.history ? [...initialTask.history] : []) setActiveTab(initialTab) + } else if (initialDraft) { + setTitle(initialDraft.title || '') + setDescription(initialDraft.description || '') + setStatus(initialDraft.status || initialStatus) + setProjectId(initialDraft.projectId) + setCategoryId(initialDraft.categoryId) + setAssigneeUserId(initialDraft.assigneeUserId) + setExternalUrl('') + setLocalHistory([]) + setActiveTab('details') } else { setTitle('') setDescription('') @@ -134,6 +199,10 @@ export const CreateTaskModal: React.FC = ({ } setError(null) + setEnrichMessage(null) + setAiQuickPrompt('') + setIsParsingInline(false) + setIsEnriching(false) setShowAddProject(false) setShowAddCategory(false) setIsHistoryFormOpen(false) @@ -456,6 +525,53 @@ export const CreateTaskModal: React.FC = ({ {/* TAB 1: General Details */} {activeTab === 'details' && (
+ {/* Inline AI Quick Prompt (for creating new tasks) */} + {!initialTask && ( +
+
+
+ + Preencher com IA +
+ Linguagem Natural +
+
+ setAiQuickPrompt(e.target.value)} + placeholder="Ex: Corrigir erro no login, categoria Bugfix, projeto BeeTask..." + className="flex-1 px-3 py-1.5 rounded-lg border border-purple-500/30 bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-1 focus:ring-purple-400" + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleInlineAiParse() + } + }} + /> + +
+
+ )} + {/* Title */}