fix(tasks): corrigir resolucao do responsavel e extracao de externalUrl com IA

This commit is contained in:
Gabriel Menezes 2026-11-06 10:44:09 -04:00
parent b8c6ef0593
commit df368218fd
10 changed files with 219 additions and 14 deletions

View file

@ -6,5 +6,6 @@ public record ParsedTaskResponse(
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId, Long assigneeUserId,
String externalUrl,
String status String status
) {} ) {}

View file

@ -207,6 +207,7 @@ public class TaskController {
output.projectId(), output.projectId(),
output.categoryId(), output.categoryId(),
output.assigneeUserId(), output.assigneeUserId(),
output.externalUrl(),
output.status() output.status()
); );
} }

View file

@ -13,14 +13,22 @@ import net.beetechgroup.beetask.usecase.repository.ProjectRepository;
import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import java.text.Normalizer;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
public class ParseTaskWithAiUseCase { public class ParseTaskWithAiUseCase {
private static final Logger LOGGER = Logger.getLogger(ParseTaskWithAiUseCase.class); 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 OllamaService ollamaService;
private final ProjectRepository projectRepository; private final ProjectRepository projectRepository;
private final CategoryRepository categoryRepository; 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(" \"description\": \"Descrição detalhada com escopo e checklist de critérios de aceitação se aplicável\",\n");
promptBuilder.append(" \"projectId\": <ID numérico do projeto correspondente ou null>,\n"); promptBuilder.append(" \"projectId\": <ID numérico do projeto correspondente ou null>,\n");
promptBuilder.append(" \"categoryId\": <ID numérico da categoria correspondente ou null>,\n"); promptBuilder.append(" \"categoryId\": <ID numérico da categoria correspondente ou null>,\n");
promptBuilder.append(" \"assigneeUserId\": <ID numérico do membro responsável citado ou null>\n"); promptBuilder.append(" \"assigneeUserId\": <ID numérico do membro responsável citado ou null>,\n");
promptBuilder.append(" \"externalUrl\": <URL completa se citada no texto (ex: https://github.com/..., https://jira...., Figma, PR, issue) ou null>\n");
promptBuilder.append("}\n\n"); promptBuilder.append("}\n\n");
promptBuilder.append("Contexto disponível na organização:\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()) { if (members.isEmpty()) {
promptBuilder.append(" (Nenhum membro cadastrado)\n"); promptBuilder.append(" (Nenhum membro cadastrado)\n");
} else { } else {
@ -114,9 +123,10 @@ public class ParseTaskWithAiUseCase {
} }
promptBuilder.append("\nRegras mandatórias:\n"); 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("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 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("2. Se houver qualquer link ou URL mencionado na instrução, capture a URL completa no campo externalUrl.\n");
promptBuilder.append("3. Não inclua texto antes ou depois do JSON."); 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 String rawJson = ollamaService != null
? ollamaService.chat(promptBuilder.toString(), input.prompt(), true) ? ollamaService.chat(promptBuilder.toString(), input.prompt(), true)
@ -143,11 +153,8 @@ public class ParseTaskWithAiUseCase {
categoryId = null; categoryId = null;
} }
Long assigneeUserId = node.hasNonNull("assigneeUserId") ? node.path("assigneeUserId").asLong() : null; Long assigneeUserId = resolveAssignee(node, input.prompt(), members, validMemberUserIds);
if (assigneeUserId != null && !validMemberUserIds.contains(assigneeUserId)) { String externalUrl = extractExternalUrl(node, input.prompt());
LOGGER.infof("AI sugeriu assigneeUserId %d inválido para a org, descartando...", assigneeUserId);
assigneeUserId = null;
}
return new ParsedTaskOutput( return new ParsedTaskOutput(
title, title,
@ -155,6 +162,7 @@ public class ParseTaskWithAiUseCase {
projectId, projectId,
categoryId, categoryId,
assigneeUserId, assigneeUserId,
externalUrl,
"NOT_STARTED" "NOT_STARTED"
); );
} catch (Exception e) { } catch (Exception e) {
@ -168,8 +176,195 @@ public class ParseTaskWithAiUseCase {
"", "",
null, null,
null, null,
null, resolveAssignee(null, input.prompt(), members, validMemberUserIds),
extractExternalUrl(null, input.prompt()),
"NOT_STARTED" "NOT_STARTED"
); );
} }
private Long resolveAssignee(JsonNode node, String prompt, List<UserOrganization> members, Set<Long> 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<UserOrganization> 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<UserOrganization> members) {
if (prompt == null || prompt.isBlank() || members == null) {
return null;
}
String normPrompt = normalize(prompt);
// 1. Nomes completos primeiro (ordenados por tamanho decrescente)
List<UserOrganization> 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("(?<!\\p{L})" + escaped + "(?!\\p{L})", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE);
return p.matcher(text).find();
}
private String normalize(String str) {
if (str == null) return "";
return Normalizer.normalize(str.trim().toLowerCase(), Normalizer.Form.NFD)
.replaceAll("\\p{M}", "");
}
} }

View file

@ -6,5 +6,6 @@ public record ParsedTaskOutput(
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId, Long assigneeUserId,
String externalUrl,
String status String status
) {} ) {}

View file

@ -124,6 +124,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
if (parsed.projectId !== undefined) setProjectId(parsed.projectId) if (parsed.projectId !== undefined) setProjectId(parsed.projectId)
if (parsed.categoryId !== undefined) setCategoryId(parsed.categoryId) if (parsed.categoryId !== undefined) setCategoryId(parsed.categoryId)
if (parsed.assigneeUserId !== undefined) setAssigneeUserId(parsed.assigneeUserId) if (parsed.assigneeUserId !== undefined) setAssigneeUserId(parsed.assigneeUserId)
if (parsed.externalUrl !== undefined) setExternalUrl(parsed.externalUrl || '')
setAiQuickPrompt('') setAiQuickPrompt('')
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Falha ao processar com IA.' const msg = err instanceof Error ? err.message : 'Falha ao processar com IA.'
@ -179,7 +180,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
setProjectId(initialDraft.projectId) setProjectId(initialDraft.projectId)
setCategoryId(initialDraft.categoryId) setCategoryId(initialDraft.categoryId)
setAssigneeUserId(initialDraft.assigneeUserId) setAssigneeUserId(initialDraft.assigneeUserId)
setExternalUrl('') setExternalUrl(initialDraft.externalUrl || '')
setLocalHistory([]) setLocalHistory([])
setActiveTab('details') setActiveTab('details')
} else { } else {
@ -224,7 +225,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
if (memRes.status === 'fulfilled') { if (memRes.status === 'fulfilled') {
const memberList = memRes.value || [] const memberList = memRes.value || []
setMembers(memberList) setMembers(memberList)
if (!initialTask && user?.email) { if (!initialTask && !initialDraft?.assigneeUserId && user?.email) {
const currentMember = memberList.find( const currentMember = memberList.find(
(m) => m.userEmail?.toLowerCase() === user.email.toLowerCase() (m) => m.userEmail?.toLowerCase() === user.email.toLowerCase()
) )
@ -241,7 +242,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
} }
loadMetadata() loadMetadata()
}, [isOpen, organizationId, initialStatus, initialTask, initialTab, user?.email]) }, [isOpen, organizationId, initialStatus, initialTask, initialDraft, initialTab, user?.email])
// Total seconds calculated dynamically from localHistory // Total seconds calculated dynamically from localHistory
const totalCalculatedSeconds = useMemo(() => { const totalCalculatedSeconds = useMemo(() => {

View file

@ -66,6 +66,7 @@ export interface ParsedAiTask {
projectId?: number; projectId?: number;
categoryId?: number; categoryId?: number;
assigneeUserId?: number; assigneeUserId?: number;
externalUrl?: string;
status?: TaskStatus; status?: TaskStatus;
} }

View file

@ -100,6 +100,8 @@ export class BeeTaskApiClient {
organizationId: number; organizationId: number;
projectId?: number; projectId?: number;
categoryId?: number; categoryId?: number;
assigneeUserId?: number;
externalUrl?: string;
}): Promise<Task> { }): Promise<Task> {
return await this.request<Task>('/tasks', { return await this.request<Task>('/tasks', {
method: 'POST', method: 'POST',

View file

@ -360,6 +360,8 @@ export function registerCommands(
organizationId: orgId, organizationId: orgId,
projectId: parsed.projectId, projectId: parsed.projectId,
categoryId: parsed.categoryId, categoryId: parsed.categoryId,
assigneeUserId: parsed.assigneeUserId,
externalUrl: parsed.externalUrl,
}); });
vscode.window.showInformationMessage(`✨ Tarefa #${created.id} criada via IA!`); vscode.window.showInformationMessage(`✨ Tarefa #${created.id} criada via IA!`);

View file

@ -63,5 +63,6 @@ export interface ParsedAiTask {
projectId?: number; projectId?: number;
categoryId?: number; categoryId?: number;
assigneeUserId?: number; assigneeUserId?: number;
externalUrl?: string;
status?: TaskStatus; status?: TaskStatus;
} }