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
}