feat(backend): implement step 6 — AiService with OpenAI RestClient integration
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user