diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java index e61fe62..d498e83 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/report/ReportSection.java @@ -11,6 +11,7 @@ public class ReportSection { private String sectionType; // 'FREE_TEXT', 'FIXED_TEXT', 'DATE', 'TASK_LIST' private String defaultValue; private String dateFormat; + private String aiPrompt; private Category filterCategory; private String filterStatus; // 'ALL', 'COMPLETED', 'IN_PROGRESS' private Boolean includeTime; @@ -105,6 +106,14 @@ public class ReportSection { this.dateFormat = dateFormat; } + public String getAiPrompt() { + return aiPrompt; + } + + public void setAiPrompt(String aiPrompt) { + this.aiPrompt = aiPrompt; + } + public LocalDateTime getCreatedAt() { return createdAt; } 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 new file mode 100644 index 0000000..a5b6b9b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/ai/OllamaService.java @@ -0,0 +1,100 @@ +package net.beetechgroup.beetask.frameworks.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@ApplicationScoped +public class OllamaService { + private static final Logger LOGGER = Logger.getLogger(OllamaService.class); + + @ConfigProperty(name = "app.ollama.endpoint", defaultValue = "http://beea-ollama:11434") + String endpoint; + + @ConfigProperty(name = "app.ollama.model", defaultValue = "qwen3.5:0.8b") + String model; + + @ConfigProperty(name = "app.ollama.timeout-seconds", defaultValue = "45") + int timeoutSeconds; + + @Inject + ObjectMapper objectMapper; + + private final HttpClient httpClient = HttpClient.newBuilder() + .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."; + } + + 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); + + List> messages = List.of( + Map.of("role", "system", "content", effectiveSystemPrompt), + Map.of("role", "user", "content", tasksContext) + ); + payload.put("messages", messages); + + Map options = Map.of( + "num_ctx", 8192, + "temperature", 0.3 + ); + payload.put("options", options); + + String requestBody = objectMapper.writeValueAsString(payload); + String url = endpoint.replaceAll("/+$", "") + "/api/chat"; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(timeoutSeconds)) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + LOGGER.infof("Enviando requisição de resumo para Ollama (%s) no modelo %s...", url, model); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + JsonNode root = objectMapper.readTree(response.body()); + String content = root.path("message").path("content").asText(""); + + // Sanitização defensiva caso o modelo emita tags de pensamento + String sanitized = content.replaceAll("(?s).*?", "").trim(); + if (sanitized.startsWith("Thinking Process:")) { + sanitized = sanitized.substring(sanitized.indexOf("\n\n") + 2).trim(); + } + + LOGGER.infof("Resumo com IA gerado 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()); + return null; + } + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java index 1ab19cc..f4c92b9 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/ReportUseCaseConfig.java @@ -2,7 +2,9 @@ package net.beetechgroup.beetask.frameworks.config; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; +import net.beetechgroup.beetask.frameworks.ai.OllamaService; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.delete.DeleteReportTemplateUseCase; @@ -86,7 +88,8 @@ public class ReportUseCaseConfig { UserRepository userRepository, StorageRepository storageRepository, net.beetechgroup.beetask.usecase.repository.UserSignatureRepository userSignatureRepository, - AuthorizeOrganizationMemberUseCase authorizer + AuthorizeOrganizationMemberUseCase authorizer, + OllamaService ollamaService ) { return new GenerateReportUseCase( reportTemplateRepository, @@ -97,7 +100,25 @@ public class ReportUseCaseConfig { userRepository, storageRepository, userSignatureRepository, - authorizer + authorizer, + ollamaService + ); + } + + @Produces + public GenerateAiSummaryUseCase generateAiSummaryUseCase( + ReportTemplateRepository reportTemplateRepository, + TaskRepository taskRepository, + UserRepository userRepository, + AuthorizeOrganizationMemberUseCase authorizer, + OllamaService ollamaService + ) { + return new GenerateAiSummaryUseCase( + reportTemplateRepository, + taskRepository, + userRepository, + authorizer, + ollamaService ); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java index 6154237..434c197 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/ReportSectionEntity.java @@ -38,6 +38,9 @@ public class ReportSectionEntity { @Column(name = "date_format", length = 100) private String dateFormat; + @Column(name = "ai_prompt", columnDefinition = "TEXT") + private String aiPrompt; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "filter_category_id") private CategoryEntity filterCategory; @@ -142,6 +145,14 @@ public class ReportSectionEntity { this.dateFormat = dateFormat; } + public String getAiPrompt() { + return aiPrompt; + } + + public void setAiPrompt(String aiPrompt) { + this.aiPrompt = aiPrompt; + } + public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java index 8c7550a..d7a71ae 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/ReportEntityMapper.java @@ -54,6 +54,7 @@ public class ReportEntityMapper { entity.setSectionType(domain.getSectionType()); entity.setDefaultValue(domain.getDefaultValue()); entity.setDateFormat(domain.getDateFormat()); + entity.setAiPrompt(domain.getAiPrompt()); entity.setFilterStatus(domain.getFilterStatus()); entity.setIncludeTime(domain.getIncludeTime()); entity.setOrderIndex(domain.getOrderIndex()); @@ -108,6 +109,7 @@ public class ReportEntityMapper { domain.setSectionType(entity.getSectionType()); domain.setDefaultValue(entity.getDefaultValue()); domain.setDateFormat(entity.getDateFormat()); + domain.setAiPrompt(entity.getAiPrompt()); domain.setFilterStatus(entity.getFilterStatus()); domain.setIncludeTime(entity.getIncludeTime()); domain.setOrderIndex(entity.getOrderIndex()); diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryRequest.java new file mode 100644 index 0000000..23bb0c2 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryRequest.java @@ -0,0 +1,11 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.report; + +import java.time.LocalDate; + +public class GenerateAiSummaryRequest { + public Long templateId; + public String sectionKey; + public LocalDate startDate; + public LocalDate endDate; + public String customPrompt; +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryResponse.java new file mode 100644 index 0000000..c88e2dc --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/GenerateAiSummaryResponse.java @@ -0,0 +1,11 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.report; + +public class GenerateAiSummaryResponse { + public String summary; + + public GenerateAiSummaryResponse() {} + + public GenerateAiSummaryResponse(String summary) { + this.summary = summary; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java index da8a137..0d61c47 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/report/ReportController.java @@ -24,6 +24,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import net.beetechgroup.beetask.entities.report.GeneratedReport; import net.beetechgroup.beetask.usecase.report.ReportTemplateOutput; +import net.beetechgroup.beetask.usecase.report.ai.GenerateAiSummaryUseCase; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateInput; import net.beetechgroup.beetask.usecase.report.create.CreateReportTemplateUseCase; import net.beetechgroup.beetask.usecase.report.update.UpdateReportTemplateInput; @@ -62,6 +63,9 @@ public class ReportController { @Inject GenerateReportUseCase generateReportUseCase; + @Inject + GenerateAiSummaryUseCase generateAiSummaryUseCase; + @Inject ListGeneratedReportsUseCase listGeneratedReportsUseCase; @@ -227,6 +231,26 @@ public class ReportController { .build(); } + @POST + @Path("/ai-summary") + @Operation(summary = "Generate AI summary for report section", description = "Generates text summary for tasks in period using Ollama") + public GenerateAiSummaryResponse generateAiSummary( + @PathParam("orgId") Long orgId, + GenerateAiSummaryRequest req + ) { + String email = securityIdentity.getPrincipal().getName(); + String summary = generateAiSummaryUseCase.execute( + email, + orgId, + req.templateId, + req.sectionKey, + req.startDate, + req.endDate, + req.customPrompt + ); + return new GenerateAiSummaryResponse(summary); + } + @GET @Path("/history") @Operation(summary = "List generated reports history", description = "Lists reports generated in this organization") diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java index 35022c2..4c7f330 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportMapper.java @@ -48,6 +48,7 @@ public class ReportMapper { section.getSectionType(), section.getDefaultValue(), section.getDateFormat(), + section.getAiPrompt(), section.getFilterCategory() != null ? section.getFilterCategory().getId() : null, section.getFilterCategory() != null ? section.getFilterCategory().getName() : null, section.getFilterStatus(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java index b5279a7..44402f0 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ReportSectionOutput.java @@ -9,6 +9,7 @@ public record ReportSectionOutput( String sectionType, String defaultValue, String dateFormat, + String aiPrompt, Long filterCategoryId, String filterCategoryName, String filterStatus, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ai/GenerateAiSummaryUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ai/GenerateAiSummaryUseCase.java new file mode 100644 index 0000000..89c03f5 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/ai/GenerateAiSummaryUseCase.java @@ -0,0 +1,175 @@ +package net.beetechgroup.beetask.usecase.report.ai; + +import net.beetechgroup.beetask.entities.User; +import net.beetechgroup.beetask.entities.report.ReportSection; +import net.beetechgroup.beetask.entities.report.ReportTemplate; +import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.entities.task.TaskHistoryItem; +import net.beetechgroup.beetask.frameworks.ai.OllamaService; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.ReportTemplateRepository; +import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.UserRepository; +import org.jboss.logging.Logger; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class GenerateAiSummaryUseCase { + private static final Logger LOGGER = Logger.getLogger(GenerateAiSummaryUseCase.class); + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("dd/MM/yyyy"); + + private final ReportTemplateRepository reportTemplateRepository; + private final TaskRepository taskRepository; + private final UserRepository userRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + private final OllamaService ollamaService; + + public GenerateAiSummaryUseCase( + ReportTemplateRepository reportTemplateRepository, + TaskRepository taskRepository, + UserRepository userRepository, + AuthorizeOrganizationMemberUseCase authorizer, + OllamaService ollamaService + ) { + this.reportTemplateRepository = reportTemplateRepository; + this.taskRepository = taskRepository; + this.userRepository = userRepository; + this.authorizer = authorizer; + this.ollamaService = ollamaService; + } + + public String execute( + String userEmail, + Long organizationId, + Long templateId, + String sectionKey, + LocalDate startDate, + LocalDate endDate, + String customPrompt + ) { + authorizer.execute(userEmail, organizationId); + + User user = userRepository.findByEmail(userEmail) + .orElseThrow(() -> new IllegalArgumentException("Usuário não encontrado: " + userEmail)); + + LocalDateTime startDt = startDate != null ? startDate.atStartOfDay() : LocalDate.now().withDayOfMonth(1).atStartOfDay(); + LocalDateTime endDt = endDate != null ? endDate.atTime(23, 59, 59) : LocalDate.now().atTime(23, 59, 59); + + String systemPrompt = customPrompt; + Long filterCategoryId = null; + + if (templateId != null) { + ReportTemplate template = reportTemplateRepository.findReportTemplateById(templateId).orElse(null); + if (template != null && template.getSections() != null && sectionKey != null) { + for (ReportSection s : template.getSections()) { + if (sectionKey.equalsIgnoreCase(s.getSectionKey())) { + if (systemPrompt == null || systemPrompt.isBlank()) { + systemPrompt = s.getAiPrompt(); + } + if (s.getFilterCategory() != null) { + filterCategoryId = s.getFilterCategory().getId(); + } + break; + } + } + } + } + + // Carregar tarefas do usuário no período + List workedTasks = taskRepository.findTasksWorkedByUserInPeriod(userEmail, startDt, endDt, organizationId); + List finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(userEmail, startDt, endDt); + + Map uniqueTasks = new LinkedHashMap<>(); + for (Task t : workedTasks) { + if (matchesOrg(t, organizationId)) { + uniqueTasks.put(t.getId(), t); + } + } + for (Task t : finishedTasks) { + if (matchesOrg(t, organizationId)) { + uniqueTasks.put(t.getId(), t); + } + } + + List allUserTasks = taskRepository.findTasksByUser(userEmail); + for (Task t : allUserTasks) { + if (matchesOrg(t, organizationId) && hasActivityInPeriod(t, startDt, endDt)) { + uniqueTasks.put(t.getId(), t); + } + } + + List taskList = new ArrayList<>(uniqueTasks.values()); + + // Filtrar por categoria se aplicável + if (filterCategoryId != null) { + final Long finalCatId = filterCategoryId; + taskList = taskList.stream() + .filter(t -> t.getCategory() != null && Objects.equals(t.getCategory().getId(), finalCatId)) + .toList(); + } + + String tasksContext = buildTasksContext(taskList, startDate, endDate); + LOGGER.infof("Gerando resumo com IA para %s (%d tarefas)...", userEmail, taskList.size()); + + String summary = ollamaService != null ? ollamaService.generateSummary(systemPrompt, tasksContext) : null; + return summary != null ? summary : "Não foi possível gerar o resumo com IA neste momento."; + } + + private boolean matchesOrg(Task t, Long orgId) { + return t.getOrganization() == null || Objects.equals(t.getOrganization().getId(), orgId); + } + + private boolean hasActivityInPeriod(Task t, LocalDateTime startDt, LocalDateTime endDt) { + if (t.getFinishedAt() != null && !t.getFinishedAt().isBefore(startDt) && !t.getFinishedAt().isAfter(endDt)) { + return true; + } + if (t.getHistory() != null) { + for (TaskHistoryItem h : t.getHistory()) { + if (h.getStartAt() != null && !h.getStartAt().isAfter(endDt)) { + LocalDateTime itemEnd = h.getEndAt() != null ? h.getEndAt() : LocalDateTime.now(); + if (!itemEnd.isBefore(startDt)) { + return true; + } + } + } + } + return false; + } + + private String buildTasksContext(List tasks, LocalDate start, LocalDate end) { + if (tasks == null || tasks.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(); + sb.append("Tarefas do período (") + .append(start != null ? start.format(DATE_FORMATTER) : "") + .append(" a ") + .append(end != null ? end.format(DATE_FORMATTER) : "") + .append("):\n"); + + for (int i = 0; i < tasks.size(); i++) { + Task t = tasks.get(i); + sb.append(i + 1).append(". "); + if (t.getCategory() != null) { + sb.append("[").append(t.getCategory().getName()).append("] "); + } + sb.append(t.getTitle()); + if (t.getProject() != null) { + sb.append(" (Projeto: ").append(t.getProject().getName()).append(")"); + } + if (t.getDescription() != null && !t.getDescription().isBlank()) { + String cleanDesc = t.getDescription().replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim(); + if (!cleanDesc.isEmpty()) { + sb.append(" - ").append(cleanDesc); + } + } + sb.append("\n"); + } + return sb.toString(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java index 84645fb..bdd15fb 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateInput.java @@ -24,6 +24,7 @@ public record CreateReportTemplateInput( String sectionType, String defaultValue, String dateFormat, + String aiPrompt, Long filterCategoryId, String filterStatus, Boolean includeTime, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java index 84bf615..05577c7 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/create/CreateReportTemplateUseCase.java @@ -109,6 +109,7 @@ public class CreateReportTemplateUseCase { section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null); section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null); + section.setAiPrompt(sInput.aiPrompt() != null ? sInput.aiPrompt().trim() : null); section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java index d4e615d..2a2821f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java @@ -51,6 +51,7 @@ public class GenerateReportUseCase { private final StorageRepository storageRepository; private final UserSignatureRepository userSignatureRepository; private final AuthorizeOrganizationMemberUseCase authorizer; + private final net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService; public GenerateReportUseCase( ReportTemplateRepository reportTemplateRepository, @@ -61,7 +62,8 @@ public class GenerateReportUseCase { UserRepository userRepository, StorageRepository storageRepository, UserSignatureRepository userSignatureRepository, - AuthorizeOrganizationMemberUseCase authorizer + AuthorizeOrganizationMemberUseCase authorizer, + net.beetechgroup.beetask.frameworks.ai.OllamaService ollamaService ) { this.reportTemplateRepository = reportTemplateRepository; this.generatedReportRepository = generatedReportRepository; @@ -72,6 +74,7 @@ public class GenerateReportUseCase { this.storageRepository = storageRepository; this.userSignatureRepository = userSignatureRepository; this.authorizer = authorizer; + this.ollamaService = ollamaService; } public GenerateReportOutput execute(GenerateReportInput input) { @@ -252,6 +255,20 @@ public class GenerateReportUseCase { if (!variables.containsKey(section.getSectionKey())) { variables.put(section.getSectionKey(), ""); } + } else if ("AI_TEXT".equalsIgnoreCase(section.getSectionType()) || "AI_SUMMARY".equalsIgnoreCase(section.getSectionType())) { + String existing = variables.get(section.getSectionKey()); + if (existing == null || existing.isBlank()) { + List aiTasks = taskList; + if (section.getFilterCategory() != null) { + Long catId = section.getFilterCategory().getId(); + aiTasks = taskList.stream() + .filter(t -> t.getCategory() != null && Objects.equals(t.getCategory().getId(), catId)) + .toList(); + } + String ctx = buildAiTasksContext(aiTasks, input.startDate(), input.endDate()); + String summary = ollamaService != null ? ollamaService.generateSummary(section.getAiPrompt(), ctx) : null; + variables.put(section.getSectionKey(), summary != null ? summary : ""); + } } } } @@ -504,4 +521,34 @@ public class GenerateReportUseCase { default -> strSlashStart + " até " + strSlashEnd; }; } + + private String buildAiTasksContext(List tasks, LocalDate start, LocalDate end) { + if (tasks == null || tasks.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(); + sb.append("Tarefas do período (") + .append(start != null ? start.format(DATE_FORMATTER) : "") + .append(" a ") + .append(end != null ? end.format(DATE_FORMATTER) : "") + .append("):\n"); + + for (int i = 0; i < tasks.size(); i++) { + Task t = tasks.get(i); + sb.append(i + 1).append(". "); + if (t.getCategory() != null) { + sb.append("[").append(t.getCategory().getName()).append("] "); + } + sb.append(t.getTitle()); + if (t.getProject() != null) { + sb.append(" (Projeto: ").append(t.getProject().getName()).append(")"); + } + if (t.getDescription() != null && !t.getDescription().isBlank()) { + String cleanDesc = t.getDescription().replaceAll("<[^>]*>", " ").replaceAll("\\s+", " ").trim(); + if (!cleanDesc.isEmpty()) { + sb.append(" - ").append(cleanDesc); + } + } + sb.append("\n"); + } + return sb.toString(); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java index 7c8e921..ffbe911 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/update/UpdateReportTemplateUseCase.java @@ -97,6 +97,7 @@ public class UpdateReportTemplateUseCase { section.setSectionType(sInput.sectionType() != null ? sInput.sectionType() : "FREE_TEXT"); section.setDefaultValue(sInput.defaultValue() != null ? sInput.defaultValue().trim() : null); section.setDateFormat(sInput.dateFormat() != null ? sInput.dateFormat().trim() : null); + section.setAiPrompt(sInput.aiPrompt() != null ? sInput.aiPrompt().trim() : null); section.setFilterStatus(sInput.filterStatus() != null ? sInput.filterStatus() : "ALL"); section.setIncludeTime(sInput.includeTime() != null ? sInput.includeTime() : true); section.setOrderIndex(sInput.orderIndex() != null ? sInput.orderIndex() : order++); diff --git a/backend/src/main/resources/db/migration/V15__add_report_section_ai_prompt.sql b/backend/src/main/resources/db/migration/V15__add_report_section_ai_prompt.sql new file mode 100644 index 0000000..ba55d81 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__add_report_section_ai_prompt.sql @@ -0,0 +1 @@ +ALTER TABLE report_sections ADD COLUMN IF NOT EXISTS ai_prompt TEXT; diff --git a/frontend/src/components/reports/CreateTemplateModal.tsx b/frontend/src/components/reports/CreateTemplateModal.tsx index cb2b4e5..3bdeb1d 100644 --- a/frontend/src/components/reports/CreateTemplateModal.tsx +++ b/frontend/src/components/reports/CreateTemplateModal.tsx @@ -29,9 +29,10 @@ interface CreateTemplateModalProps { interface SectionFormItem { sectionKey: string title: string - sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' + sectionType: 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT' defaultValue?: string dateFormat?: string + aiPrompt?: string filterCategoryId?: number filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' includeTime: boolean @@ -128,6 +129,7 @@ export const CreateTemplateModal: React.FC = ({ sectionType: s.sectionType, defaultValue: s.defaultValue ? s.defaultValue.trim() : '', dateFormat: s.sectionType === 'DATE' ? (s.dateFormat || 'DD/MM/AAAA') : undefined, + aiPrompt: s.sectionType === 'AI_TEXT' ? (s.aiPrompt?.trim() || undefined) : undefined, filterCategoryId: s.filterCategoryId, filterStatus: s.filterStatus || 'ALL', includeTime: s.includeTime ?? true, @@ -377,7 +379,7 @@ export const CreateTemplateModal: React.FC = ({ value={section.sectionType} onChange={(val) => handleUpdateSection(idx, { - sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE', + sectionType: val as 'FREE_TEXT' | 'FIXED_TEXT' | 'DATE' | 'TASK_LIST' | 'SIGNATURE' | 'AI_TEXT', }) } options={[ @@ -385,12 +387,67 @@ export const CreateTemplateModal: React.FC = ({ { value: 'FIXED_TEXT', label: 'Texto Fixo (Pré-preenchido no cadastro)' }, { value: 'DATE', label: 'Data (Com formato personalizado)' }, { value: 'SIGNATURE', label: '✍️ Assinatura Digital (Assinatura do usuário)' }, + { value: 'AI_TEXT', label: '✨ Resumo / Texto com IA (Ollama)' }, { value: 'TASK_LIST', label: 'Lista de Tarefas do Período' }, ]} /> + {/* Configuração de Campo de Tipo IA */} + {section.sectionType === 'AI_TEXT' && ( +
+
+ +
+

Geração Inteligente com IA Local (Ollama / Qwen)

+

+ A IA lerá as tarefas do colaborador no período (título, descrição, categoria, projeto) e gerará o resumo conforme o prompt configurado abaixo. Na emissão do relatório, o usuário poderá clicar em "Gerar com IA" e editar o resultado antes de baixar o documento. +

+
+
+ +
+ +