234 lines
8.2 KiB
C#
234 lines
8.2 KiB
C#
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
|
||
}
|