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 index 51cee3d..8c47e02 100644 --- 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 @@ -6,5 +6,6 @@ public record ParsedTaskResponse( Long projectId, Long categoryId, Long assigneeUserId, + String externalUrl, 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 4e08b4d..3e1045c 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 @@ -207,6 +207,7 @@ public class TaskController { output.projectId(), output.categoryId(), output.assigneeUserId(), + output.externalUrl(), output.status() ); } 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 index e175315..db3350f 100644 --- 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 @@ -13,14 +13,22 @@ import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import org.jboss.logging.Logger; +import java.text.Normalizer; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; public class ParseTaskWithAiUseCase { private static final Logger LOGGER = Logger.getLogger(ParseTaskWithAiUseCase.class); + private static final Pattern URL_PATTERN = Pattern.compile( + "https?://[a-zA-Z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=%]+", + Pattern.CASE_INSENSITIVE + ); + private final OllamaService ollamaService; private final ProjectRepository projectRepository; private final CategoryRepository categoryRepository; @@ -77,7 +85,8 @@ public class ParseTaskWithAiUseCase { 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(" \"assigneeUserId\": ,\n"); + promptBuilder.append(" \"externalUrl\": \n"); promptBuilder.append("}\n\n"); promptBuilder.append("Contexto disponível na organização:\n"); @@ -99,7 +108,7 @@ public class ParseTaskWithAiUseCase { } } - promptBuilder.append("Membros disponíveis:\n"); + promptBuilder.append("Membros disponíveis (use o ID correspondente se o membro for citado pelo nome ou email):\n"); if (members.isEmpty()) { promptBuilder.append(" (Nenhum membro cadastrado)\n"); } else { @@ -114,9 +123,10 @@ public class ParseTaskWithAiUseCase { } 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."); + promptBuilder.append("1. Apenas retorne IDs numéricos (projectId, categoryId, assigneeUserId) que estejam explicitamente listados acima. Se um membro for citado por nome ou email (mesmo primeiro nome), retorne o ID numérico desse membro. Caso não encontre correspondência direta, use null.\n"); + promptBuilder.append("2. Se houver qualquer link ou URL mencionado na instrução, capture a URL completa no campo externalUrl.\n"); + promptBuilder.append("3. 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("4. Não inclua texto antes ou depois do JSON."); String rawJson = ollamaService != null ? ollamaService.chat(promptBuilder.toString(), input.prompt(), true) @@ -143,11 +153,8 @@ public class ParseTaskWithAiUseCase { 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; - } + Long assigneeUserId = resolveAssignee(node, input.prompt(), members, validMemberUserIds); + String externalUrl = extractExternalUrl(node, input.prompt()); return new ParsedTaskOutput( title, @@ -155,6 +162,7 @@ public class ParseTaskWithAiUseCase { projectId, categoryId, assigneeUserId, + externalUrl, "NOT_STARTED" ); } catch (Exception e) { @@ -168,8 +176,195 @@ public class ParseTaskWithAiUseCase { "", null, null, - null, + resolveAssignee(null, input.prompt(), members, validMemberUserIds), + extractExternalUrl(null, input.prompt()), "NOT_STARTED" ); } + + private Long resolveAssignee(JsonNode node, String prompt, List members, Set validMemberUserIds) { + // 1. Verificação direta de assigneeUserId + if (node != null && node.hasNonNull("assigneeUserId")) { + JsonNode aNode = node.path("assigneeUserId"); + if (aNode.isNumber()) { + long id = aNode.asLong(); + if (validMemberUserIds.contains(id)) { + return id; + } + } else if (aNode.isTextual()) { + String text = aNode.asText("").trim(); + try { + long id = Long.parseLong(text); + if (validMemberUserIds.contains(id)) { + return id; + } + } catch (NumberFormatException ignored) { + Long matched = matchMemberByNameOrEmail(text, members); + if (matched != null) { + return matched; + } + } + } + } + + // 2. Verificação de campos alternativos que a IA possa ter retornado + if (node != null) { + for (String field : List.of("assignee", "assigneeName", "responsible", "user")) { + if (node.hasNonNull(field)) { + String name = node.path(field).asText("").trim(); + if (!name.isBlank()) { + Long matched = matchMemberByNameOrEmail(name, members); + if (matched != null) { + return matched; + } + } + } + } + } + + // 3. Fallback inteligente: buscar correspondência de membro diretamente no prompt + return matchMemberInText(prompt, members); + } + + private Long matchMemberByNameOrEmail(String text, List members) { + if (text == null || text.isBlank() || members == null) { + return null; + } + String cleanTarget = normalize(text); + + // Correspondência exata por e-mail ou prefixo do e-mail + for (UserOrganization m : members) { + if (m.getUser() == null || m.getUser().getId() == null) continue; + String email = m.getUser().getEmail(); + if (email != null && !email.isBlank()) { + String normEmail = normalize(email); + if (normEmail.equalsIgnoreCase(cleanTarget)) { + return m.getUser().getId(); + } + String username = normEmail.split("@")[0]; + if (username.equalsIgnoreCase(cleanTarget)) { + return m.getUser().getId(); + } + } + } + + // Correspondência exata por nome completo + for (UserOrganization m : members) { + if (m.getUser() == null || m.getUser().getId() == null) continue; + String name = m.getUser().getName(); + if (name != null && !name.isBlank()) { + String normName = normalize(name); + if (normName.equalsIgnoreCase(cleanTarget)) { + return m.getUser().getId(); + } + } + } + + // Correspondência por primeiro nome (se comprimento >= 3 e não ambíguo) + Long matchedId = null; + int matchCount = 0; + for (UserOrganization m : members) { + if (m.getUser() == null || m.getUser().getId() == null) continue; + String name = m.getUser().getName(); + if (name != null && !name.isBlank()) { + String normName = normalize(name); + String firstName = normName.split("\\s+")[0]; + if (firstName.equalsIgnoreCase(cleanTarget) && firstName.length() >= 3) { + matchedId = m.getUser().getId(); + matchCount++; + } + } + } + if (matchCount == 1) { + return matchedId; + } + + return null; + } + + private Long matchMemberInText(String prompt, List members) { + if (prompt == null || prompt.isBlank() || members == null) { + return null; + } + String normPrompt = normalize(prompt); + + // 1. Nomes completos primeiro (ordenados por tamanho decrescente) + List sortedMembers = members.stream() + .filter(m -> m.getUser() != null && m.getUser().getId() != null && m.getUser().getName() != null) + .sorted((a, b) -> Integer.compare(b.getUser().getName().length(), a.getUser().getName().length())) + .toList(); + + for (UserOrganization m : sortedMembers) { + String normName = normalize(m.getUser().getName()); + if (normName.length() >= 3 && containsWordOrPhrase(normPrompt, normName)) { + return m.getUser().getId(); + } + } + + // 2. E-mails citados no texto + for (UserOrganization m : members) { + if (m.getUser() == null || m.getUser().getId() == null) continue; + String email = m.getUser().getEmail(); + if (email != null && !email.isBlank()) { + String normEmail = normalize(email); + if (containsWordOrPhrase(normPrompt, normEmail)) { + return m.getUser().getId(); + } + } + } + + // 3. Primeiro nome no texto (não ambíguo) + Long matchedId = null; + int matchCount = 0; + for (UserOrganization m : members) { + if (m.getUser() == null || m.getUser().getId() == null) continue; + String name = m.getUser().getName(); + if (name != null && !name.isBlank()) { + String normName = normalize(name); + String firstName = normName.split("\\s+")[0]; + if (firstName.length() >= 3 && containsWordOrPhrase(normPrompt, firstName)) { + matchedId = m.getUser().getId(); + matchCount++; + } + } + } + if (matchCount == 1) { + return matchedId; + } + + return null; + } + + private String extractExternalUrl(JsonNode node, String prompt) { + if (node != null && node.hasNonNull("externalUrl")) { + String url = node.path("externalUrl").asText("").trim(); + if (!url.isBlank()) { + if (url.startsWith("http://") || url.startsWith("https://")) { + return url; + } else if (url.startsWith("www.") || url.contains(".com") || url.contains(".org") || url.contains(".dev") || url.contains(".io") || url.contains(".net")) { + return "https://" + url; + } + } + } + // Fallback: extrair URL diretamente do prompt via regex + if (prompt != null) { + Matcher matcher = URL_PATTERN.matcher(prompt); + if (matcher.find()) { + return matcher.group().replaceAll("[.,;)]+$", "").trim(); + } + } + return null; + } + + private boolean containsWordOrPhrase(String text, String target) { + String escaped = Pattern.quote(target); + Pattern p = Pattern.compile("(? = ({ if (parsed.projectId !== undefined) setProjectId(parsed.projectId) if (parsed.categoryId !== undefined) setCategoryId(parsed.categoryId) if (parsed.assigneeUserId !== undefined) setAssigneeUserId(parsed.assigneeUserId) + if (parsed.externalUrl !== undefined) setExternalUrl(parsed.externalUrl || '') setAiQuickPrompt('') } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Falha ao processar com IA.' @@ -179,7 +180,7 @@ export const CreateTaskModal: React.FC = ({ setProjectId(initialDraft.projectId) setCategoryId(initialDraft.categoryId) setAssigneeUserId(initialDraft.assigneeUserId) - setExternalUrl('') + setExternalUrl(initialDraft.externalUrl || '') setLocalHistory([]) setActiveTab('details') } else { @@ -224,7 +225,7 @@ export const CreateTaskModal: React.FC = ({ if (memRes.status === 'fulfilled') { const memberList = memRes.value || [] setMembers(memberList) - if (!initialTask && user?.email) { + if (!initialTask && !initialDraft?.assigneeUserId && user?.email) { const currentMember = memberList.find( (m) => m.userEmail?.toLowerCase() === user.email.toLowerCase() ) @@ -241,7 +242,7 @@ export const CreateTaskModal: React.FC = ({ } loadMetadata() - }, [isOpen, organizationId, initialStatus, initialTask, initialTab, user?.email]) + }, [isOpen, organizationId, initialStatus, initialTask, initialDraft, initialTab, user?.email]) // Total seconds calculated dynamically from localHistory const totalCalculatedSeconds = useMemo(() => { diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index e5946ec..54be3ac 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -66,6 +66,7 @@ export interface ParsedAiTask { projectId?: number; categoryId?: number; assigneeUserId?: number; + externalUrl?: string; status?: TaskStatus; } diff --git a/vscode-extension/bee-tech-plugin-1.2.0.vsix b/vscode-extension/bee-tech-plugin-1.2.0.vsix index e05c52c..4bfa554 100644 Binary files a/vscode-extension/bee-tech-plugin-1.2.0.vsix and b/vscode-extension/bee-tech-plugin-1.2.0.vsix differ diff --git a/vscode-extension/src/api.ts b/vscode-extension/src/api.ts index 50aa23e..9971b6b 100644 --- a/vscode-extension/src/api.ts +++ b/vscode-extension/src/api.ts @@ -100,6 +100,8 @@ export class BeeTaskApiClient { organizationId: number; projectId?: number; categoryId?: number; + assigneeUserId?: number; + externalUrl?: string; }): Promise { return await this.request('/tasks', { method: 'POST', diff --git a/vscode-extension/src/commands.ts b/vscode-extension/src/commands.ts index eb19bf4..9448c08 100644 --- a/vscode-extension/src/commands.ts +++ b/vscode-extension/src/commands.ts @@ -360,6 +360,8 @@ export function registerCommands( organizationId: orgId, projectId: parsed.projectId, categoryId: parsed.categoryId, + assigneeUserId: parsed.assigneeUserId, + externalUrl: parsed.externalUrl, }); vscode.window.showInformationMessage(`✨ Tarefa #${created.id} criada via IA!`); diff --git a/vscode-extension/src/types.ts b/vscode-extension/src/types.ts index 5d027c9..1f10827 100644 --- a/vscode-extension/src/types.ts +++ b/vscode-extension/src/types.ts @@ -63,5 +63,6 @@ export interface ParsedAiTask { projectId?: number; categoryId?: number; assigneeUserId?: number; + externalUrl?: string; status?: TaskStatus; }