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