Files
AdPlatform-Server/IntelligenceApi/Engines/General/GeneralEngine.cs
2026-03-14 13:50:09 -07:00

479 lines
20 KiB
C#
Raw 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 IntelligenceApi.Models;
using System.Diagnostics;
using System.Text.Json;
namespace IntelligenceApi.Engines.General;
/// <summary>
/// Default spend distribution engine for General (small business) clients.
///
/// This is a direct transplant of ForecastService from the Gateway.
/// Behavior is identical to the original — existing clients see no change.
///
/// Algorithm:
/// 1. Fan out to provider APIs in parallel (Google live, others emulated)
/// 2. Normalize metrics across providers
/// 3. Score each channel using objective-weighted metrics
/// 4. Derive allocation percentages (min 15%, max 85%)
/// 5. Return sorted channel estimates with recommendation text
///
/// No AI cost — runs entirely on rules + provider data.
/// </summary>
public sealed class GeneralEngine : ISpendDistributionEngine
{
private readonly IHttpClientFactory _http;
private readonly ILogger<GeneralEngine> _logger;
private const int MIN_ALLOCATION = 15;
private const int MAX_ALLOCATION = 85;
private static readonly JsonSerializerOptions _jsonOpts =
new(JsonSerializerDefaults.Web);
public string EngineName => "General";
public GeneralEngine(IHttpClientFactory http, ILogger<GeneralEngine> logger)
{
_http = http;
_logger = logger;
}
public async Task<SpendDistributionResponse> RecommendAsync(
SpendDistributionRequest request, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
var channels = request.Channels ?? new List<string> { "google_ads" };
var weights = ObjectiveWeights.For(request.Objective);
_logger.LogInformation(
"[GeneralEngine] Starting | Objective={Obj} Budget={Budget} Channels={Ch}",
request.Objective, request.MonthlyBudget, string.Join(",", channels));
// ── Fan out to providers in parallel ──
var tasks = new Dictionary<string, Task<ProviderResult>>();
foreach (var channel in channels)
{
tasks[channel] = channel switch
{
"google_ads" => FetchGoogleAsync(request, ct),
"meta" => FetchMetaAsync(request, ct),
_ => Task.FromResult(TemplateForecast(channel))
};
}
await Task.WhenAll(tasks.Values);
var results = tasks.ToDictionary(t => t.Key, t => t.Value.Result);
// ── Score and allocate ──
var scored = ScoreChannels(results, weights);
var allocations = DeriveAllocations(scored);
// ── Build response ──
var channelAllocations = new List<ChannelAllocation>();
foreach (var (channel, result) in results)
{
var pct = allocations[channel];
var allocated = Math.Round(request.MonthlyBudget * pct / 100m, 2);
channelAllocations.Add(new ChannelAllocation
{
Provider = channel,
AllocationPercent = pct,
AllocatedBudget = allocated,
Estimates = new AllocationMetrics
{
Impressions = result.Impressions,
Reach = result.Reach,
Clicks = result.Clicks,
Conversions = result.Conversions,
AvgCpc = result.AvgCpc,
AvgCpm = result.AvgCpm,
EstimatedCpa = result.EstimatedCpa,
Ctr = result.Ctr
},
EfficiencyScore = Math.Round(scored[channel], 3),
StrengthLabel = GetStrengthLabel(channel, request.Objective),
Confidence = result.Confidence,
DataSource = result.DataSource
});
}
channelAllocations.Sort((a, b) => b.AllocationPercent.CompareTo(a.AllocationPercent));
sw.Stop();
_logger.LogInformation("[GeneralEngine] Complete | Elapsed={Ms}ms", sw.ElapsedMilliseconds);
return new SpendDistributionResponse
{
Ok = true,
Objective = request.Objective,
TotalBudget = request.MonthlyBudget,
Channels = channelAllocations,
Recommendation = BuildRecommendation(channelAllocations, request.Objective),
Metadata = new DistributionMeta
{
GeneratedAt = DateTimeOffset.UtcNow,
ForecastPeriod = "30 days",
Engine = EngineName
}
};
}
// ════════════════════════════════════════════════
// Provider calls
// ════════════════════════════════════════════════
private async Task<ProviderResult> FetchGoogleAsync(
SpendDistributionRequest request, CancellationToken ct)
{
try
{
var providerUrl = request.ProviderUrls?.GetValueOrDefault("google_ads") ?? "";
var key = request.InternalKeys?.GetValueOrDefault("google_ads") ?? "";
if (string.IsNullOrWhiteSpace(providerUrl))
return EmulatedGoogle(request);
var payload = new
{
keywords = request.Keywords,
geoTargetIds = request.GeoTargeting?.GeoTargetIds ?? new List<long>(),
monthlyBudget = request.MonthlyBudget,
currencyCode = "USD",
forecastDays = 30
};
var providerRequest = new
{
operation = "KeywordForecast",
requestId = Guid.NewGuid().ToString("N"),
payload
};
var httpClient = _http.CreateClient();
using var msg = new HttpRequestMessage(
HttpMethod.Post, $"{providerUrl}/internal/execute");
msg.Headers.Add("X-Internal-Key", key);
msg.Content = new StringContent(
JsonSerializer.Serialize(providerRequest, _jsonOpts),
System.Text.Encoding.UTF8, "application/json");
using var resp = await httpClient.SendAsync(msg, ct);
var body = await resp.Content.ReadAsStringAsync(ct);
if (!resp.IsSuccessStatusCode)
{
_logger.LogWarning("[GeneralEngine] Google provider {Status}", (int)resp.StatusCode);
return EmulatedGoogle(request);
}
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
var data = root.TryGetProperty("data", out var d) ? d : root;
var monthly = data.GetProperty("monthly");
var metrics = data.GetProperty("metrics");
return new ProviderResult
{
Provider = "google_ads",
Impressions = monthly.TryGetProperty("impressions", out var imp) ? imp.GetDouble() : 0,
Clicks = monthly.TryGetProperty("clicks", out var cl) ? cl.GetDouble() : 0,
Conversions = monthly.TryGetProperty("conversions", out var conv) ? conv.GetDouble() : 0,
Reach = null,
AvgCpc = metrics.TryGetProperty("avgCpc", out var cpc) ? cpc.GetDecimal() : 0,
AvgCpm = metrics.TryGetProperty("avgCpm", out var cpm) ? cpm.GetDecimal() : 0,
Ctr = metrics.TryGetProperty("ctr", out var ctr) ? ctr.GetDouble() : 0,
EstimatedCpa = metrics.TryGetProperty("estimatedCpa", out var cpa) && cpa.ValueKind != JsonValueKind.Null
? cpa.GetDecimal() : null,
Confidence = data.TryGetProperty("confidence", out var cf) ? cf.GetString() ?? "low" : "low",
DataSource = data.TryGetProperty("dataSource", out var ds) ? ds.GetString() ?? "emulated" : "emulated"
};
}
catch (Exception ex)
{
_logger.LogError(ex, "[GeneralEngine] Google provider call failed");
return EmulatedGoogle(request);
}
}
private static async Task<ProviderResult> FetchMetaAsync(
SpendDistributionRequest request, CancellationToken ct)
{
// Phase 2: call MetaApi /internal/execute → DeliveryEstimate
await Task.CompletedTask;
var budget = request.MonthlyBudget;
var rng = new Random((int)(budget * 77));
var v = 0.85 + (rng.NextDouble() * 0.30);
var cpm = 12.50m + (decimal)(rng.NextDouble() * 8.0);
var impressions = budget > 0 ? (double)(budget / cpm) * 1000 * v : 0;
var reach = impressions * 0.42;
var clickRate = 0.012 + (rng.NextDouble() * 0.008);
var clicks = impressions * clickRate;
var conversions = clicks * (0.025 + rng.NextDouble() * 0.015);
var avgCpc = clicks > 0 ? budget / (decimal)clicks : 0;
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
return new ProviderResult
{
Provider = "meta",
Impressions = Math.Round(impressions),
Clicks = Math.Round(clicks),
Conversions = Math.Round(conversions, 1),
Reach = Math.Round(reach),
AvgCpc = Math.Round(avgCpc, 2),
AvgCpm = Math.Round(cpm, 2),
Ctr = Math.Round(clickRate, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
Confidence = "low",
DataSource = "emulated"
};
}
private static ProviderResult TemplateForecast(string provider) =>
new() { Provider = provider, Confidence = "none", DataSource = "template" };
private ProviderResult EmulatedGoogle(SpendDistributionRequest request)
{
var budget = request.MonthlyBudget;
var kwCount = Math.Max(request.Keywords.Count, 1);
var rng = new Random((int)(budget * 100) + kwCount);
var v = 0.85 + (rng.NextDouble() * 0.30);
var baseCpc = 2.50m - (decimal)(Math.Min(kwCount, 20) / 20.0 * 1.20);
var clicks = budget > 0 ? (double)(budget / baseCpc) * v : 0;
var impressions = clicks / 0.045;
var conversions = clicks * 0.035;
var ctr = impressions > 0 ? clicks / impressions : 0;
var cpm = impressions > 0 ? (double)(budget / (decimal)impressions) * 1000 : 0;
var cpa = conversions > 0 ? (decimal?)(budget / (decimal)conversions) : null;
return new ProviderResult
{
Provider = "google_ads",
Impressions = Math.Round(impressions),
Clicks = Math.Round(clicks),
Conversions = Math.Round(conversions, 1),
AvgCpc = Math.Round(baseCpc, 2),
AvgCpm = Math.Round((decimal)cpm, 2),
Ctr = Math.Round(ctr, 4),
EstimatedCpa = cpa.HasValue ? Math.Round(cpa.Value, 2) : null,
Confidence = "low",
DataSource = "emulated"
};
}
// ════════════════════════════════════════════════
// Scoring
// ════════════════════════════════════════════════
private static Dictionary<string, double> ScoreChannels(
Dictionary<string, ProviderResult> results, MetricWeights w)
{
var scoreable = results
.Where(r => r.Value.DataSource != "template")
.ToDictionary(r => r.Key, r => r.Value);
if (scoreable.Count == 0)
return results.ToDictionary(r => r.Key, _ => 1.0);
var maxImp = scoreable.Values.Max(r => r.Impressions);
var maxReach = scoreable.Values.Max(r => r.Reach ?? 0);
var maxClicks = scoreable.Values.Max(r => r.Clicks);
var maxConv = scoreable.Values.Max(r => r.Conversions);
var maxCtr = scoreable.Values.Max(r => r.Ctr);
var minCpm = scoreable.Values.Where(r => r.AvgCpm > 0).Select(r => r.AvgCpm).DefaultIfEmpty(1).Min();
var minCpc = scoreable.Values.Where(r => r.AvgCpc > 0).Select(r => r.AvgCpc).DefaultIfEmpty(1).Min();
var minCpa = scoreable.Values.Where(r => r.EstimatedCpa > 0)
.Select(r => r.EstimatedCpa!.Value).DefaultIfEmpty(1).Min();
var scores = new Dictionary<string, double>();
foreach (var (channel, r) in scoreable)
{
double score = 0;
score += w.Impressions * SafeDiv(r.Impressions, maxImp);
score += w.Reach * SafeDiv(r.Reach ?? 0, maxReach > 0 ? maxReach : 1);
score += w.Clicks * SafeDiv(r.Clicks, maxClicks);
score += w.Conversions * SafeDiv(r.Conversions, maxConv);
score += w.Ctr * SafeDiv(r.Ctr, maxCtr);
score += w.Cpm * (r.AvgCpm > 0 ? (double)(minCpm / r.AvgCpm) : 0);
score += w.Cpc * (r.AvgCpc > 0 ? (double)(minCpc / r.AvgCpc) : 0);
score += w.Cpa * (r.EstimatedCpa > 0 ? (double)(minCpa / r.EstimatedCpa!.Value) : 0);
scores[channel] = score;
}
var avg = scores.Values.Average();
foreach (var channel in results.Keys.Except(scoreable.Keys))
scores[channel] = avg * 0.5;
return scores;
}
private static Dictionary<string, int> DeriveAllocations(Dictionary<string, double> scores)
{
var total = scores.Values.Sum();
if (total == 0)
{
var even = 100 / scores.Count;
return scores.ToDictionary(s => s.Key, _ => even);
}
var raw = scores.ToDictionary(
s => s.Key,
s => Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION,
(int)Math.Round(s.Value / total * 100))));
var sum = raw.Values.Sum();
if (sum != 100 && raw.Count > 0)
{
var diff = 100 - sum;
var top = raw.OrderByDescending(r => r.Value).First().Key;
raw[top] = Math.Max(MIN_ALLOCATION, Math.Min(MAX_ALLOCATION, raw[top] + diff));
}
return raw;
}
// ════════════════════════════════════════════════
// Copy helpers (keep identical to Gateway originals)
// ════════════════════════════════════════════════
private static double SafeDiv(double n, double d) => d > 0 ? n / d : 0;
private static string GetStrengthLabel(string channel, string objective) => channel switch
{
"google_ads" => objective switch
{
"awareness" => "Strong for search visibility",
"traffic" => "Strong for search intent clicks",
"leads" => "Strong for high-intent leads",
"sales" => "Strong for purchase intent",
_ => "Search & intent targeting"
},
"meta" => objective switch
{
"awareness" => "Strong for reach & discovery",
"traffic" => "Strong for social traffic",
"leads" => "Strong for lead gen forms",
"sales" => "Strong for retargeting & social proof",
_ => "Social reach & engagement"
},
"tiktok" => "Video-first engagement",
_ => "Advertising channel"
};
private static DistributionRecommendation BuildRecommendation(
List<ChannelAllocation> channels, string objective)
{
if (channels.Count < 2)
return new DistributionRecommendation
{
Summary = $"Budget allocated to {channels.FirstOrDefault()?.Provider ?? "channel"} for {objective}.",
Highlights = new List<string>()
};
var top = channels[0];
var second = channels[1];
var highlights = new List<string>();
if (top.Estimates.Clicks > 0 && second.Estimates.Clicks > 0)
{
var ratio = top.Estimates.Clicks / second.Estimates.Clicks;
if (ratio > 1.3)
highlights.Add($"{DisplayName(top.Provider)}: ~{ratio:F0}x more clicks per dollar");
}
if (top.Estimates.Impressions > 0 && second.Estimates.Impressions > 0)
{
var ratio = second.Estimates.Impressions / top.Estimates.Impressions;
if (ratio > 1.5)
highlights.Add($"{DisplayName(second.Provider)}: ~{ratio:F0}x more impressions per dollar");
}
if (top.Estimates.EstimatedCpa > 0 && second.Estimates.EstimatedCpa > 0)
{
highlights.Add(
$"CPA range: ${Math.Min(top.Estimates.EstimatedCpa!.Value, second.Estimates.EstimatedCpa!.Value):F0}" +
$"${Math.Max(top.Estimates.EstimatedCpa.Value, second.Estimates.EstimatedCpa.Value):F0} across channels");
}
return new DistributionRecommendation
{
Summary = $"Recommended {top.AllocationPercent}/{second.AllocationPercent} split " +
$"between {DisplayName(top.Provider)} and {DisplayName(second.Provider)}, " +
$"optimized for {objective}.",
Highlights = highlights
};
}
private static string DisplayName(string p) => p switch
{
"google_ads" => "Google",
"meta" => "Meta",
"tiktok" => "TikTok",
_ => p
};
// ── Internal result from a single provider ──
private sealed class ProviderResult
{
public string Provider { get; set; } = string.Empty;
public double Impressions { get; set; }
public double? Reach { get; set; }
public double Clicks { get; set; }
public double Conversions { get; set; }
public decimal AvgCpc { get; set; }
public decimal AvgCpm { get; set; }
public double Ctr { get; set; }
public decimal? EstimatedCpa { get; set; }
public string Confidence { get; set; } = "none";
public string DataSource { get; set; } = "none";
}
}
// ════════════════════════════════════════════════
// Objective-weighted scoring (copied from Gateway ForecastModels)
// ════════════════════════════════════════════════
public sealed class MetricWeights
{
public double Reach { get; }
public double Impressions { get; }
public double Cpm { get; }
public double Clicks { get; }
public double Cpc { get; }
public double Ctr { get; }
public double Conversions { get; }
public double Cpa { get; }
public MetricWeights(double reach, double impressions, double cpm,
double clicks, double cpc, double ctr, double conversions, double cpa)
{
Reach = reach; Impressions = impressions; Cpm = cpm;
Clicks = clicks; Cpc = cpc; Ctr = ctr;
Conversions = conversions; Cpa = cpa;
}
}
public static class ObjectiveWeights
{
private static readonly Dictionary<string, MetricWeights> _weights =
new(StringComparer.OrdinalIgnoreCase)
{
// reach imp cpm clicks cpc ctr conv cpa
["awareness"] = new MetricWeights(0.35, 0.25, 0.20, 0.05, 0.05, 0.05, 0.00, 0.00),
["traffic"] = new MetricWeights(0.05, 0.10, 0.10, 0.30, 0.30, 0.15, 0.00, 0.00),
["leads"] = new MetricWeights(0.05, 0.05, 0.05, 0.15, 0.15, 0.10, 0.25, 0.20),
["sales"] = new MetricWeights(0.05, 0.05, 0.05, 0.10, 0.10, 0.10, 0.30, 0.25),
};
private static readonly MetricWeights _default =
new(0.10, 0.10, 0.10, 0.20, 0.20, 0.10, 0.10, 0.10);
public static MetricWeights For(string objective) =>
_weights.TryGetValue(objective, out var w) ? w : _default;
}