fix(tasks): corrigir resolucao do responsavel e extracao de externalUrl com IA
This commit is contained in:
parent
b8c6ef0593
commit
df368218fd
10 changed files with 219 additions and 14 deletions
|
|
@ -6,5 +6,6 @@ public record ParsedTaskResponse(
|
|||
Long projectId,
|
||||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String externalUrl,
|
||||
String status
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ public class TaskController {
|
|||
output.projectId(),
|
||||
output.categoryId(),
|
||||
output.assigneeUserId(),
|
||||
output.externalUrl(),
|
||||
output.status()
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\": <ID numérico do projeto 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("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<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}", "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@ public record ParsedTaskOutput(
|
|||
Long projectId,
|
||||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String externalUrl,
|
||||
String status
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
|
|||
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<CreateTaskModalProps> = ({
|
|||
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<CreateTaskModalProps> = ({
|
|||
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<CreateTaskModalProps> = ({
|
|||
}
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface ParsedAiTask {
|
|||
projectId?: number;
|
||||
categoryId?: number;
|
||||
assigneeUserId?: number;
|
||||
externalUrl?: string;
|
||||
status?: TaskStatus;
|
||||
}
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -100,6 +100,8 @@ export class BeeTaskApiClient {
|
|||
organizationId: number;
|
||||
projectId?: number;
|
||||
categoryId?: number;
|
||||
assigneeUserId?: number;
|
||||
externalUrl?: string;
|
||||
}): Promise<Task> {
|
||||
return await this.request<Task>('/tasks', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -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!`);
|
||||
|
|
|
|||
|
|
@ -63,5 +63,6 @@ export interface ParsedAiTask {
|
|||
projectId?: number;
|
||||
categoryId?: number;
|
||||
assigneeUserId?: number;
|
||||
externalUrl?: string;
|
||||
status?: TaskStatus;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue