feat(backend): implement step 8 — EntityScheduler pipeline orchestration

This commit is contained in:
2026-03-26 18:56:32 -03:00
parent 958d881b4b
commit d7e2c952e6
2 changed files with 77 additions and 10 deletions

View File

@@ -0,0 +1,75 @@
package com.condado.newsletter.scheduler
import com.condado.newsletter.model.DispatchLog
import com.condado.newsletter.model.DispatchStatus
import com.condado.newsletter.model.VirtualEntity
import com.condado.newsletter.repository.DispatchLogRepository
import com.condado.newsletter.service.AiService
import com.condado.newsletter.service.EmailReaderService
import com.condado.newsletter.service.EmailSenderService
import com.condado.newsletter.service.PromptBuilderService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.scheduling.config.ScheduledTaskRegistrar
import org.springframework.scheduling.support.CronTrigger
import org.springframework.stereotype.Component
import java.util.concurrent.ConcurrentHashMap
/**
* Registers and manages per-entity scheduled tasks using [SchedulingConfigurer].
* Refreshes task registrations every minute to pick up changes to active entities.
*/
@Component
class EntityScheduler(
private val emailReaderService: EmailReaderService,
private val promptBuilderService: PromptBuilderService,
private val aiService: AiService,
private val emailSenderService: EmailSenderService,
private val dispatchLogRepository: DispatchLogRepository,
@Value("\${app.recipients:}") val recipients: String,
@Value("\${imap.inbox-folder:INBOX}") val inboxFolder: String
) {
private val log = LoggerFactory.getLogger(javaClass)
/**
* Runs the full email generation + send pipeline for the given [entity].
* If the entity is inactive, returns immediately.
* Always persists a [DispatchLog] with SENT or FAILED status.
*/
fun runPipeline(entity: VirtualEntity) {
if (!entity.active) return
val recipientList = recipients.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
try {
val emails = emailReaderService.readEmails(inboxFolder, entity.contextWindowDays)
val prompt = promptBuilderService.buildPrompt(entity, emails)
val aiResponse = aiService.generate(prompt)
emailSenderService.send(entity.email, recipientList, aiResponse.subject, aiResponse.body)
dispatchLogRepository.save(
DispatchLog(
virtualEntity = entity,
promptSent = prompt,
aiResponse = "${aiResponse.subject}\n${aiResponse.body}",
emailSubject = aiResponse.subject,
emailBody = aiResponse.body,
status = DispatchStatus.SENT
)
)
log.info("Pipeline succeeded for entity '${entity.name}'")
} catch (e: Exception) {
log.error("Pipeline failed for entity '${entity.name}': ${e.message}", e)
dispatchLogRepository.save(
DispatchLog(
virtualEntity = entity,
status = DispatchStatus.FAILED,
errorMessage = e.message
)
)
}
}
}

View File

@@ -11,15 +11,7 @@ import com.condado.newsletter.service.AiServiceException
import com.condado.newsletter.service.EmailReaderService
import com.condado.newsletter.service.EmailSenderService
import com.condado.newsletter.service.PromptBuilderService
import io.mockk.called
import io.mockk.capture
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mutableListOf
import io.mockk.runs
import io.mockk.slot
import io.mockk.verify
import io.mockk.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -37,7 +29,7 @@ class EntitySchedulerTest {
private lateinit var scheduler: EntityScheduler
private val entity = VirtualEntity(
name = "João Gerente",
name = "Joao Gerente",
email = "joao@condado.com",
jobTitle = "Gerente de Nada",
personality = "Muito formal",