Files
AdPlatform-Server/Creative/Services/CopyGeneratorService.cs
2026-03-14 13:50:09 -07:00

234 lines
8.2 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using Creative.Configuration;
using Creative.Models;
using Microsoft.Extensions.Options;
namespace Creative.Services;
/// <summary>
/// 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
/// </summary>
public class CopyGeneratorService
{
private const int MaxHeadlineChars = 30;
private const int MaxDescriptionChars = 90;
private readonly CreativeConfig _config;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger<CopyGeneratorService> _logger;
public CopyGeneratorService(
IOptions<CreativeConfig> config,
IHttpClientFactory httpFactory,
ILogger<CopyGeneratorService> logger)
{
_config = config.Value;
_httpFactory = httpFactory;
_logger = logger;
}
/// <summary>
/// Generate text assets from analyzed URL content.
/// Returns validated headlines and descriptions.
/// </summary>
public async Task<(List<TextAsset> Headlines, List<TextAsset> 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<TextAsset>, List<TextAsset>, 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<TextAsset>, List<TextAsset>, 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<TextAsset>();
var descriptions = new List<TextAsset>();
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<TextAsset>, List<TextAsset>, 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<string>
{
$"{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<string>
{
$"{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
/// <summary>
/// Validate and truncate asset text to meet Google Ads character limits.
/// </summary>
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
}