using System.Text; using System.Text.Json; using Creative.Configuration; using Creative.Models; using Microsoft.Extensions.Options; namespace Creative.Services; /// /// Generates Google Ads text assets (headlines + descriptions). /// Uses OpenAI API when enabled, emulated data otherwise. /// /// Google Ads specs: /// Headlines: max 30 characters, up to 15 per RSA /// Descriptions: max 90 characters, up to 4 per RSA /// public class CopyGeneratorService { private const int MaxHeadlineChars = 30; private const int MaxDescriptionChars = 90; private readonly CreativeConfig _config; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; public CopyGeneratorService( IOptions config, IHttpClientFactory httpFactory, ILogger logger) { _config = config.Value; _httpFactory = httpFactory; _logger = logger; } /// /// Generate text assets from analyzed URL content. /// Returns validated headlines and descriptions. /// public async Task<(List Headlines, List Descriptions, string Source)> GenerateAsync(UrlAnalysis analysis, CancellationToken ct) { if (!_config.EnableRealApi || string.IsNullOrWhiteSpace(_config.OpenAiApiKey)) return EmulateGeneration(analysis); return await GenerateRealAsync(analysis, ct); } #region Real Implementation (OpenAI) private async Task<(List, List, string)> GenerateRealAsync( UrlAnalysis analysis, CancellationToken ct) { _logger.LogInformation("[CopyGen] Calling OpenAI for {Url}", analysis.Url); var prompt = BuildPrompt(analysis); var client = _httpFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(_config.OpenAiTimeoutSeconds); client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_config.OpenAiApiKey}"); var requestBody = new { model = _config.OpenAiModel, max_tokens = _config.OpenAiMaxTokens, messages = new[] { new { role = "system", content = "You are an expert Google Ads copywriter. " + "Return ONLY valid JSON with no markdown formatting. " + "Follow character limits exactly." }, new { role = "user", content = prompt } } }; var json = JsonSerializer.Serialize(requestBody); using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var response = await client.PostAsync("https://api.openai.com/v1/chat/completions", content, ct); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(ct); _logger.LogError("[CopyGen] OpenAI returned {Status}: {Body}", response.StatusCode, errorBody); throw new InvalidOperationException($"OpenAI API returned {response.StatusCode}"); } var responseJson = await response.Content.ReadAsStringAsync(ct); return ParseOpenAiResponse(responseJson); } private static string BuildPrompt(UrlAnalysis analysis) { var sb = new StringBuilder(); sb.AppendLine("Generate Google Ads copy for this business."); sb.AppendLine(); sb.AppendLine($"URL: {analysis.Url}"); sb.AppendLine($"Title: {analysis.Title}"); sb.AppendLine($"Description: {analysis.MetaDescription}"); if (analysis.Headings.Count > 0) sb.AppendLine($"Headings: {string.Join(", ", analysis.Headings)}"); if (!string.IsNullOrWhiteSpace(analysis.BodySnippet)) sb.AppendLine($"Content: {analysis.BodySnippet}"); sb.AppendLine(); sb.AppendLine("Requirements:"); sb.AppendLine("- 10 headlines, each MAXIMUM 30 characters"); sb.AppendLine("- 4 descriptions, each MAXIMUM 90 characters"); sb.AppendLine("- Headlines should be punchy and action-oriented"); sb.AppendLine("- Descriptions should expand on value and include a call to action"); sb.AppendLine("- Do NOT use excessive punctuation or ALL CAPS"); sb.AppendLine(); sb.AppendLine("Return JSON only, no markdown:"); sb.AppendLine("""{"headlines":["..."],"descriptions":["..."]}"""); return sb.ToString(); } private (List, List, string) ParseOpenAiResponse(string responseJson) { using var doc = JsonDocument.Parse(responseJson); // Extract the content from OpenAI's response structure var messageContent = doc.RootElement .GetProperty("choices")[0] .GetProperty("message") .GetProperty("content") .GetString() ?? "{}"; // Strip markdown fences if present messageContent = messageContent .Replace("```json", "") .Replace("```", "") .Trim(); using var parsed = JsonDocument.Parse(messageContent); var headlines = new List(); var descriptions = new List(); if (parsed.RootElement.TryGetProperty("headlines", out var hArray)) { foreach (var h in hArray.EnumerateArray()) { var text = h.GetString() ?? ""; headlines.Add(ValidateAsset("headline", text, MaxHeadlineChars)); } } if (parsed.RootElement.TryGetProperty("descriptions", out var dArray)) { foreach (var d in dArray.EnumerateArray()) { var text = d.GetString() ?? ""; descriptions.Add(ValidateAsset("description", text, MaxDescriptionChars)); } } _logger.LogInformation("[CopyGen] OpenAI returned {H} headlines, {D} descriptions", headlines.Count, descriptions.Count); return (headlines, descriptions, "openai"); } #endregion #region Emulated private (List, List, string) EmulateGeneration(UrlAnalysis analysis) { _logger.LogInformation("[CopyGen] Emulated generation for {Url}", analysis.Url); var businessName = analysis.Title?.Split('-', '|', '–')[0].Trim() ?? "Our Business"; if (businessName.Length > 20) businessName = businessName[..20].Trim(); var headlines = new List { $"{businessName} Near You", $"Visit {businessName} Today", "Quality You Can Trust", "Get Started Today", "See Our Services", $"Discover {businessName}", "Book an Appointment", "Free Consultation", "Top-Rated Service", "Limited Time Offer" } .Select(h => ValidateAsset("headline", h, MaxHeadlineChars)) .ToList(); var descriptions = new List { $"{businessName} delivers quality products and services. Visit us today and see the difference.", "Trusted by thousands of customers. Get a free quote and experience our commitment to excellence.", "Looking for reliable service? We offer competitive pricing and a satisfaction guarantee.", "Join our happy customers today. Professional service, fair prices, and results that speak." } .Select(d => ValidateAsset("description", d, MaxDescriptionChars)) .ToList(); return (headlines, descriptions, "emulated"); } #endregion #region Validation /// /// Validate and truncate asset text to meet Google Ads character limits. /// private static TextAsset ValidateAsset(string type, string text, int maxChars) { text = text.Trim(); // Truncate if over limit (shouldn't happen often with good prompts) if (text.Length > maxChars) text = text[..(maxChars - 1)].TrimEnd() + "…"; return new TextAsset { Type = type, Text = text, CharCount = text.Length }; } #endregion }