feat(backend): implement step 6 — AiService with OpenAI RestClient integration

This commit is contained in:
2026-03-26 18:49:34 -03:00
parent 8885a1fb96
commit 5307856e55
5 changed files with 124 additions and 1 deletions

View File

@@ -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: <subject line>
* BODY:
* <email 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<ChatMessage>)
@JsonIgnoreProperties(ignoreUnknown = true)
data class ChatMessage(val role: String, val content: String)
}