Initial import into Gitea
This commit is contained in:
233
Creative/Services/CopyGeneratorService.cs
Normal file
233
Creative/Services/CopyGeneratorService.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user