diff --git a/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt b/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt new file mode 100644 index 0000000..8bb5694 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/config/AppConfig.kt @@ -0,0 +1,15 @@ +package com.condado.newsletter.config + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.client.RestClient + +/** + * Application-wide bean configuration. + */ +@Configuration +class AppConfig { + + @Bean + fun restClient(): RestClient = RestClient.create() +} diff --git a/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt b/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt new file mode 100644 index 0000000..e59301c --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/model/ParsedAiResponse.kt @@ -0,0 +1,9 @@ +package com.condado.newsletter.model + +/** + * The parsed result of an AI-generated email response. + */ +data class ParsedAiResponse( + val subject: String, + val body: String +) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt new file mode 100644 index 0000000..d0120b6 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AiExceptions.kt @@ -0,0 +1,7 @@ +package com.condado.newsletter.service + +/** Thrown when the OpenAI API call fails. */ +class AiServiceException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + +/** Thrown when the AI response cannot be parsed into SUBJECT/BODY format. */ +class AiParseException(message: String) : RuntimeException(message) diff --git a/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt b/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt new file mode 100644 index 0000000..49b7290 --- /dev/null +++ b/backend/src/main/kotlin/com/condado/newsletter/service/AiService.kt @@ -0,0 +1,92 @@ +package com.condado.newsletter.service + +import com.condado.newsletter.model.ParsedAiResponse +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.annotation.JsonProperty +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.web.client.RestClient + +/** + * Calls the OpenAI Chat Completions API to generate email content. + * Returns a [ParsedAiResponse] with the extracted subject and body. + */ +@Service +class AiService( + private val restClient: RestClient, + @Value("\${openai.api-key}") private val apiKey: String, + @Value("\${openai.model:gpt-4o}") private val model: String +) { + private val log = LoggerFactory.getLogger(javaClass) + + /** + * Sends [prompt] to the OpenAI API and returns the parsed subject + body. + * @throws [AiServiceException] on API errors. + * @throws [AiParseException] if the response format is unexpected. + */ + fun generate(prompt: String): ParsedAiResponse { + val rawText = try { + val request = ChatRequest( + model = model, + messages = listOf(ChatMessage(role = "user", content = prompt)) + ) + val json = restClient.post() + .uri("https://api.openai.com/v1/chat/completions") + .header("Authorization", "Bearer $apiKey") + .body(request) + .retrieve() + .body(String::class.java) + ?: throw AiServiceException("OpenAI returned an empty response") + + extractContent(json) + } catch (e: AiServiceException) { + throw e + } catch (e: Exception) { + log.error("OpenAI API call failed: ${e.message}", e) + throw AiServiceException("OpenAI API call failed: ${e.message}", e) + } + + return parseResponse(rawText) + } + + /** + * Parses the raw AI output into a [ParsedAiResponse]. + * Expected format: + * ``` + * SUBJECT: + * BODY: + * + * ``` + */ + fun parseResponse(raw: String): ParsedAiResponse { + val lines = raw.trim().lines() + + val subjectLine = lines.firstOrNull { it.startsWith("SUBJECT:", ignoreCase = true) } + ?: throw AiParseException("AI response missing SUBJECT line. Raw response: $raw") + + val bodyStart = lines.indexOfFirst { it.trim().equals("BODY:", ignoreCase = true) } + if (bodyStart == -1) throw AiParseException("AI response missing BODY: section. Raw response: $raw") + + val subject = subjectLine.replaceFirst(Regex("^SUBJECT:\\s*", RegexOption.IGNORE_CASE), "").trim() + val body = lines.drop(bodyStart + 1).joinToString("\n").trim() + + return ParsedAiResponse(subject = subject, body = body) + } + + // ── internal JSON helpers ──────────────────────────────────────────────── + + private fun extractContent(json: String): String { + val mapper = com.fasterxml.jackson.databind.ObjectMapper() + val tree = mapper.readTree(json) + return tree["choices"]?.firstOrNull()?.get("message")?.get("content")?.asText() + ?.replace("\\n", "\n") + ?: throw AiServiceException("Unexpected OpenAI response structure: $json") + } + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ChatRequest(val model: String, val messages: List) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class ChatMessage(val role: String, val content: String) +} diff --git a/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt b/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt index 0113390..74bcf93 100644 --- a/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt +++ b/backend/src/test/kotlin/com/condado/newsletter/service/AiServiceTest.kt @@ -25,7 +25,7 @@ class AiServiceTest { @Test fun should_returnAiResponseText_when_apiCallSucceeds() { val rawResponse = "SUBJECT: Test Subject\nBODY:\nTest body content" - stubRestClient(rawResponse) + stubRestClient(rawResponse.replace("\n", "\\n")) val result = service.generate("My test prompt")