feat(reports): add AI_TEXT section type powered by local Ollama qwen3.5

This commit is contained in:
Gabriel Menezes 2026-11-01 12:19:58 -04:00
parent c00a5ff4b7
commit b8c23d5faa
21 changed files with 672 additions and 9 deletions

View file

@ -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;
}

View file

@ -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<String, Object> payload = new HashMap<>();
payload.put("model", model);
payload.put("stream", false);
payload.put("think", false);
List<Map<String, String>> messages = List.of(
Map.of("role", "system", "content", effectiveSystemPrompt),
Map.of("role", "user", "content", tasksContext)
);
payload.put("messages", messages);
Map<String, Object> 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<String> 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)<think>.*?</think>", "").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;
}
}
}

View file

@ -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
);
}

View file

@ -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;
}

View file

@ -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());

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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")

View file

@ -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(),

View file

@ -9,6 +9,7 @@ public record ReportSectionOutput(
String sectionType,
String defaultValue,
String dateFormat,
String aiPrompt,
Long filterCategoryId,
String filterCategoryName,
String filterStatus,

View file

@ -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<Task> workedTasks = taskRepository.findTasksWorkedByUserInPeriod(userEmail, startDt, endDt, organizationId);
List<Task> finishedTasks = taskRepository.findTasksFinishedByUserInPeriod(userEmail, startDt, endDt);
Map<Long, Task> 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<Task> allUserTasks = taskRepository.findTasksByUser(userEmail);
for (Task t : allUserTasks) {
if (matchesOrg(t, organizationId) && hasActivityInPeriod(t, startDt, endDt)) {
uniqueTasks.put(t.getId(), t);
}
}
List<Task> 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<Task> 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();
}
}

View file

@ -24,6 +24,7 @@ public record CreateReportTemplateInput(
String sectionType,
String defaultValue,
String dateFormat,
String aiPrompt,
Long filterCategoryId,
String filterStatus,
Boolean includeTime,

View file

@ -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++);

View file

@ -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<Task> 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<Task> 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();
}
}

View file

@ -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++);

View file

@ -0,0 +1 @@
ALTER TABLE report_sections ADD COLUMN IF NOT EXISTS ai_prompt TEXT;

View file

@ -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<CreateTemplateModalProps> = ({
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<CreateTemplateModalProps> = ({
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<CreateTemplateModalProps> = ({
{ 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' },
]}
/>
</div>
</div>
{/* Configuração de Campo de Tipo IA */}
{section.sectionType === 'AI_TEXT' && (
<div className="pt-2 border-t border-border-soft/60 space-y-3 animate-in fade-in duration-150">
<div className="p-3 rounded-xl bg-purple-500/10 border border-purple-500/20 text-[11px] text-foreground/80 flex items-start gap-2.5">
<Sparkles className="w-4 h-4 text-purple-600 shrink-0 mt-0.5" />
<div>
<p className="font-bold text-purple-700 dark:text-purple-400">Geração Inteligente com IA Local (Ollama / Qwen)</p>
<p className="text-muted mt-0.5">
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 <strong>"Gerar com IA"</strong> e editar o resultado antes de baixar o documento.
</p>
</div>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Instruções para a IA (Prompt personalizado)
</label>
<textarea
rows={3}
value={section.aiPrompt || ''}
onChange={(e) => handleUpdateSection(idx, { aiPrompt: e.target.value })}
placeholder="Ex.: Resuma as atividades realizadas pelo colaborador de forma clara, técnica e concisa, destacando as entregas principais. Responda em português sem introduções ou saudações."
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 font-mono shadow-xs resize-y"
/>
<span className="text-[9px] text-muted block mt-0.5">
Deixe em branco para usar a instrução técnica padrão do sistema.
</span>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Filtrar tarefas enviadas para a IA por Categoria (Opcional)
</label>
<CustomSelect
size="sm"
value={section.filterCategoryId ?? 0}
onChange={(val) =>
handleUpdateSection(idx, {
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
})
}
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
</div>
)}
{/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">

View file

@ -31,9 +31,10 @@ interface EditTemplateModalProps {
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
@ -71,6 +72,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
sectionType: s.sectionType,
defaultValue: s.defaultValue || '',
dateFormat: s.dateFormat || 'DD/MM/AAAA',
aiPrompt: s.aiPrompt || '',
filterCategoryId: s.filterCategoryId,
filterStatus: s.filterStatus || 'ALL',
includeTime: s.includeTime ?? true,
@ -146,6 +148,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
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,
@ -399,7 +402,7 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
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={[
@ -407,12 +410,67 @@ export const EditTemplateModal: React.FC<EditTemplateModalProps> = ({
{ 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' },
]}
/>
</div>
</div>
{/* Configuração de Campo de Tipo IA */}
{section.sectionType === 'AI_TEXT' && (
<div className="pt-2 border-t border-border-soft/60 space-y-3 animate-in fade-in duration-150">
<div className="p-3 rounded-xl bg-purple-500/10 border border-purple-500/20 text-[11px] text-foreground/80 flex items-start gap-2.5">
<Sparkles className="w-4 h-4 text-purple-600 shrink-0 mt-0.5" />
<div>
<p className="font-bold text-purple-700 dark:text-purple-400">Geração Inteligente com IA Local (Ollama / Qwen)</p>
<p className="text-muted mt-0.5">
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 <strong>"Gerar com IA"</strong> e editar o resultado antes de baixar o documento.
</p>
</div>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Instruções para a IA (Prompt personalizado)
</label>
<textarea
rows={3}
value={section.aiPrompt || ''}
onChange={(e) => handleUpdateSection(idx, { aiPrompt: e.target.value })}
placeholder="Ex.: Resuma as atividades realizadas pelo colaborador de forma clara, técnica e concisa, destacando as entregas principais. Responda em português sem introduções ou saudações."
className="w-full px-2.5 py-1.5 rounded-lg border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 font-mono shadow-xs resize-y"
/>
<span className="text-[9px] text-muted block mt-0.5">
Deixe em branco para usar a instrução técnica padrão do sistema.
</span>
</div>
<div>
<label className="text-[10px] font-semibold text-muted block mb-1">
Filtrar tarefas enviadas para a IA por Categoria (Opcional)
</label>
<CustomSelect
size="sm"
value={section.filterCategoryId ?? 0}
onChange={(val) =>
handleUpdateSection(idx, {
filterCategoryId: Number(val) === 0 ? undefined : Number(val),
})
}
options={[
{ value: 0, label: 'Todas as Categorias' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color,
})),
]}
/>
</div>
</div>
)}
{/* Configuração de Campo de Tipo ASSINATURA */}
{section.sectionType === 'SIGNATURE' && (
<div className="p-3 rounded-xl bg-brand/5 border border-brand/15 text-[11px] text-foreground/80 flex items-start gap-2.5 animate-in fade-in duration-150">

View file

@ -11,9 +11,10 @@ import {
Lock,
Tag,
PenTool,
Sparkles,
} from 'lucide-react'
import { CustomSelect } from '../ui/Select'
import { reportService, type ReportTemplateOutput } from '../../services/reportService'
import { reportService, type ReportTemplateOutput, type ReportSectionOutput } from '../../services/reportService'
import type { Project } from '../../types/task'
interface GenerateReportModalProps {
@ -52,6 +53,8 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
const [endDate, setEndDate] = useState(defaultDates.end)
const [periodPreset, setPeriodPreset] = useState<'current_month' | 'last_month' | 'custom'>('current_month')
const [customFields, setCustomFields] = useState<Record<string, string>>({})
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({})
const [aiError, setAiError] = useState<Record<string, string | null>>({})
const [generating, setGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -114,6 +117,37 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
return selectedTemplate.sections.filter((s) => s.sectionType === 'SIGNATURE')
}, [selectedTemplate])
// Seções com Inteligência Artificial (Ollama / Qwen)
const aiSections = useMemo(() => {
if (!selectedTemplate) return []
return selectedTemplate.sections.filter((s) => s.sectionType === 'AI_TEXT')
}, [selectedTemplate])
const handleGenerateAi = async (section: ReportSectionOutput) => {
if (!selectedTemplateId) return
try {
setAiLoading((prev) => ({ ...prev, [section.sectionKey]: true }))
setAiError((prev) => ({ ...prev, [section.sectionKey]: null }))
const res = await reportService.generateAiSummary(orgId, {
templateId: selectedTemplateId,
sectionKey: section.sectionKey,
startDate,
endDate,
})
if (res.summary) {
setCustomFields((prev) => ({ ...prev, [section.sectionKey]: res.summary }))
}
} catch (err: unknown) {
console.error(err)
setAiError((prev) => ({
...prev,
[section.sectionKey]: err instanceof Error ? err.message : 'Falha ao gerar resumo com IA',
}))
} finally {
setAiLoading((prev) => ({ ...prev, [section.sectionKey]: false }))
}
}
// Preset Handlers
const handlePresetChange = (preset: 'current_month' | 'last_month' | 'custom') => {
setPeriodPreset(preset)
@ -483,6 +517,81 @@ export const GenerateReportModal: React.FC<GenerateReportModalProps> = ({
</div>
)}
{/* Seções com Inteligência Artificial (Ollama / Qwen) */}
{aiSections.length > 0 && (
<div className="space-y-4">
<div className="border-b border-border-soft pb-1">
<h4 className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Sparkles className="w-3.5 h-3.5 text-purple-600" />
Campos com Inteligência Artificial
</h4>
<p className="text-[11px] text-muted">
Gere o resumo de atividades com IA local (Ollama). Você pode revisar e editar o texto livremente antes de gerar o Word.
</p>
</div>
{aiSections.map((sec) => {
const isLoading = Boolean(aiLoading[sec.sectionKey])
const hasValue = Boolean(customFields[sec.sectionKey]?.trim())
const errorMsg = aiError[sec.sectionKey]
return (
<div
key={sec.sectionKey}
className="p-4 rounded-2xl border border-purple-500/20 bg-purple-500/5 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">{sec.title}</span>
<code className="text-[10px] text-purple-700 dark:text-purple-300 font-mono bg-purple-500/10 px-1.5 py-0.5 rounded">
{`{{${sec.sectionKey}}}`}
</code>
</div>
<button
type="button"
onClick={() => handleGenerateAi(sec)}
disabled={isLoading}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-600 hover:bg-purple-700 text-white text-[11px] font-bold shadow-xs transition-all cursor-pointer disabled:opacity-50"
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<span>Gerando resumo...</span>
</>
) : (
<>
<Sparkles className="w-3.5 h-3.5" />
<span>{hasValue ? 'Regerar com IA' : 'Gerar com IA'}</span>
</>
)}
</button>
</div>
{errorMsg && (
<div className="p-2.5 rounded-xl bg-red-500/10 border border-red-500/20 text-red-700 dark:text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{errorMsg}</span>
</div>
)}
<textarea
rows={4}
value={customFields[sec.sectionKey] || ''}
onChange={(e) => handleCustomFieldChange(sec.sectionKey, e.target.value)}
placeholder="Clique em 'Gerar com IA' para produzir o resumo a partir das tarefas do período, ou digite aqui..."
className="w-full p-3 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-purple-500 focus:ring-1 focus:ring-purple-500 shadow-xs resize-y"
/>
<p className="text-[10px] text-muted">
💡 Caso não clique em "Gerar com IA" e deixe este campo vazio, o backend tentará gerá-lo automaticamente ao compor o documento .DOCX.
</p>
</div>
)
})}
</div>
)}
{/* Listagens de Tarefas Configuradas no Modelo */}
{taskListSections.length > 0 && (
<div className="p-4 rounded-2xl bg-surface-raised/40 border border-border-soft space-y-2.5">

View file

@ -304,6 +304,12 @@ export const ReportsPage: React.FC = () => {
className="px-2 py-0.5 rounded-md text-[10px] font-mono font-semibold bg-surface-raised border border-border-soft text-foreground/80 inline-flex items-center gap-1"
>
<span>{`{{${s.sectionKey}}}`}</span>
{s.sectionType === 'AI_TEXT' && (
<span className="text-[9px] text-purple-600 dark:text-purple-400 font-sans font-bold">✨ IA</span>
)}
{s.sectionType === 'SIGNATURE' && (
<span className="text-[9px] text-brand font-sans font-bold">✍️</span>
)}
{s.sectionType === 'DATE' && (
<span className="text-[9px] text-brand font-sans font-bold">[{s.dateFormat || 'data'}]</span>
)}

View file

@ -4,9 +4,10 @@ export interface ReportSectionOutput {
id: number
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
filterCategoryName?: string
filterStatus?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS'
@ -62,6 +63,14 @@ export interface GenerateReportPayload {
customFields?: Record<string, string>
}
export interface GenerateAiSummaryPayload {
templateId?: number
sectionKey?: string
startDate?: string
endDate?: string
customPrompt?: string
}
export const reportService = {
async listTemplates(orgId: number, projectId?: number): Promise<ReportTemplateOutput[]> {
const query = projectId ? `?projectId=${projectId}` : ''
@ -130,4 +139,11 @@ export const reportService = {
async listHistory(orgId: number): Promise<GeneratedReportOutput[]> {
return apiFetch<GeneratedReportOutput[]>(`/organizations/${orgId}/reports/history`)
},
async generateAiSummary(orgId: number, payload: GenerateAiSummaryPayload): Promise<{ summary: string }> {
return apiFetch<{ summary: string }>(`/organizations/${orgId}/reports/ai-summary`, {
method: 'POST',
body: JSON.stringify(payload),
})
},
}