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