Initial import into Gitea

This commit is contained in:
Grae Jones
2026-03-14 13:50:09 -07:00
parent 8e7e03702e
commit 34c1f09e01
154 changed files with 17666 additions and 1548 deletions

View File

@@ -0,0 +1,91 @@
using IntelligenceApi.Models;
namespace IntelligenceApi.Engines;
/// <summary>
/// Derives audience recommendations from raw census data for a ZCTA.
///
/// This logic was previously embedded in the Gateway's DemographicsController
/// as BuildMarketAnalysis(). It belongs here — IntelligenceApi owns all
/// recommendation and analysis logic; the Gateway is a thin proxy.
///
/// Registered as a singleton: stateless, no IO.
/// </summary>
public sealed class DemographicsAnalyzer
{
public DemographicAnalysisResponse Analyze(DemographicAnalysisRequest request)
{
var c = request.Census;
var zcta = request.Zcta;
// ── Age skew ──────────────────────────────────────────────────────────
var youngPct = c.Pct18to24 + c.Pct25to34;
var maturePct = c.Pct55to64 + c.Pct65plus;
string ageSkew;
if (youngPct > maturePct + 10) ageSkew = "young";
else if (maturePct > youngPct + 10) ageSkew = "mature";
else ageSkew = "balanced";
// ── Recommended age chips ─────────────────────────────────────────────
// Include brackets with meaningful population share.
var ageRanges = new List<string>();
if (c.Pct18to24 >= 10) ageRanges.Add("AGE_18_24");
if (c.Pct25to34 >= 12) ageRanges.Add("AGE_25_34");
if (c.Pct35to44 >= 12) ageRanges.Add("AGE_35_44");
if (c.Pct45to54 >= 12) ageRanges.Add("AGE_45_54");
if (c.Pct55to64 >= 10) ageRanges.Add("AGE_55_64");
if (c.Pct65plus >= 12) ageRanges.Add("AGE_65_UP");
// Fallback: if no bracket clears the threshold, default to prime brackets
if (ageRanges.Count == 0)
{
ageRanges.Add("AGE_25_34");
ageRanges.Add("AGE_35_44");
}
// ── Recommended income chips ──────────────────────────────────────────
var incomes = c.MedianIncome switch
{
> 100_000 => new List<string> { "TOP_10", "TOP_11_20" },
> 75_000 => new List<string> { "TOP_11_20", "TOP_21_30" },
> 50_000 => new List<string> { "TOP_21_30", "TOP_31_40" },
_ => new List<string> { "TOP_41_50", "LOWER_50" }
};
// ── Human-readable insights ───────────────────────────────────────────
var insights = new List<string>();
if (c.TotalPopulation > 0) insights.Add($"{c.TotalPopulation:N0} people");
if (c.MedianIncome > 0) insights.Add($"Median income ${c.MedianIncome:N0}");
if (c.PctBachelorPlus > 0) insights.Add($"{c.PctBachelorPlus}% college-educated");
if (c.PctOwnerOccupied > 55) insights.Add($"{c.PctOwnerOccupied}% homeowners");
else if (c.PctRenterOccupied > 55) insights.Add($"{c.PctRenterOccupied}% renters");
if (c.PctFamilyHouseholds > 60) insights.Add($"{c.PctFamilyHouseholds}% families");
else if (c.PctLivingAlone > 35) insights.Add($"{c.PctLivingAlone}% single-person households");
insights.Add(ageSkew switch
{
"young" => "Skews younger (1834)",
"mature" => "Skews older (55+)",
_ => "Balanced age distribution"
});
return new DemographicAnalysisResponse
{
Ok = true,
Zcta = zcta,
Census = c,
Recommendations = new AudienceRecommendations
{
AgeRanges = ageRanges,
Incomes = incomes,
AgeSkew = ageSkew,
MarketScope = "local" // single ZIP is always local scope
},
Insights = insights
};
}
}

View File

@@ -0,0 +1,75 @@
using IntelligenceApi.Engines.Franchisee;
using IntelligenceApi.Engines.Franchisor;
using IntelligenceApi.Engines.General;
namespace IntelligenceApi.Engines;
/// <summary>
/// Routes incoming spend distribution requests to the correct engine
/// based on clientCategory.
///
/// ADDING A NEW ENGINE:
/// 1. Create a folder under Engines/ (e.g. Engines/FoodFranchisee/)
/// 2. Implement ISpendDistributionEngine
/// 3. Register in Program.cs
/// 4. Add the category string to the routing table below
///
/// The General engine is the fallback for any unrecognised category,
/// ensuring existing clients are never broken by new category additions.
/// </summary>
public sealed class EngineRouter
{
private readonly GeneralEngine _general;
private readonly FranchiseeEngine _franchisee;
private readonly FranchisorEngine _franchisor;
private readonly ILogger<EngineRouter> _logger;
public EngineRouter(
GeneralEngine general,
FranchiseeEngine franchisee,
FranchisorEngine franchisor,
ILogger<EngineRouter> logger)
{
_general = general;
_franchisee = franchisee;
_franchisor = franchisor;
_logger = logger;
}
public ISpendDistributionEngine Resolve(string? clientCategory)
{
var engine = (clientCategory ?? "General").Trim() switch
{
// ── Exact category matches ──────────────────────────────
"General" => (ISpendDistributionEngine)_general,
"Franchisee" => _franchisee,
"Franchisor" => _franchisor,
// ── Future sub-categories route to their parent stub
// until a dedicated engine is built.
// e.g. "FoodFranchisee" => _foodFranchisee (not yet registered)
// falls through to Franchisee as the nearest match.
var c when c.EndsWith("Franchisee", StringComparison.OrdinalIgnoreCase)
=> _franchisee,
var c when c.EndsWith("Franchisor", StringComparison.OrdinalIgnoreCase)
=> _franchisor,
// ── Unknown / unrecognised — safe fallback ──────────────
var c => LogAndFallback(c)
};
_logger.LogInformation(
"[EngineRouter] Category={Category} → Engine={Engine}",
clientCategory, engine.EngineName);
return engine;
}
private ISpendDistributionEngine LogAndFallback(string category)
{
_logger.LogWarning(
"[EngineRouter] Unrecognised category '{Category}' — falling back to GeneralEngine",
category);
return _general;
}
}

View File

@@ -0,0 +1,59 @@
using IntelligenceApi.Models;
namespace IntelligenceApi.Engines.Franchisee;
/// <summary>
/// Spend distribution engine for Franchisee clients.
///
/// CURRENT STATUS: Stub — delegates to GeneralEngine logic.
/// Returns General recommendations with engine name set to "Franchisee"
/// so billing and logging correctly identify the tier.
///
/// PLANNED: Premium AI-driven recommendations incorporating:
/// - Proximity analysis to sibling franchisee locations
/// (avoid cannibalisation, identify territory gaps)
/// - Local competitor density from Google Maps / Places API
/// - Demographic fit scoring per geo zone
/// - Franchisor brand guidelines (approved channels, spend floors)
/// - Historical performance benchmarks across the franchise network
/// - Dayparting patterns specific to the franchise category
/// (e.g. lunch peaks for food, weekend spikes for home services)
///
/// IMPLEMENTATION PATH:
/// 1. Inject IFranchiseeDataService (location DB + geo queries)
/// 2. Inject ICompetitorIntelligenceService (Places API or similar)
/// 3. Replace scoring weights with category-trained model output
/// 4. Surface franchise-specific highlights in DistributionRecommendation
/// </summary>
public sealed class FranchiseeEngine : ISpendDistributionEngine
{
private readonly General.GeneralEngine _general;
private readonly ILogger<FranchiseeEngine> _logger;
public string EngineName => "Franchisee";
public FranchiseeEngine(General.GeneralEngine general, ILogger<FranchiseeEngine> logger)
{
_general = general;
_logger = logger;
}
public async Task<SpendDistributionResponse> RecommendAsync(
SpendDistributionRequest request, CancellationToken ct)
{
_logger.LogInformation(
"[FranchiseeEngine] Stub — delegating to GeneralEngine | Budget={Budget}",
request.MonthlyBudget);
// Delegate to General for now
var response = await _general.RecommendAsync(request, ct);
// Override engine name so billing / logging is correct
response.Metadata.Engine = EngineName;
// TODO: Augment recommendation with franchise-specific insights
// once data services are wired in.
return response;
}
}

View File

@@ -0,0 +1,58 @@
using IntelligenceApi.Models;
namespace IntelligenceApi.Engines.Franchisor;
/// <summary>
/// Spend distribution engine for Franchisor (brand / network owner) clients.
///
/// CURRENT STATUS: Stub — delegates to GeneralEngine logic.
///
/// PLANNED: Network-level AI recommendations incorporating:
/// - Co-op budget allocation across franchisee network
/// (brand-level national vs. local tier split)
/// - Network-wide performance benchmarks and outlier detection
/// - Territory coverage analysis — identifying under-served markets
/// - Brand consistency enforcement across provider configurations
/// - Consolidated reporting roll-up across all franchisee accounts
/// - Seasonal and promotional campaign coordination
/// - Franchisee performance ranking to guide co-op investment priority
///
/// DISTINCTION FROM FRANCHISEE ENGINE:
/// Franchisee = single-location optimisation (local)
/// Franchisor = multi-location orchestration (network-wide)
///
/// IMPLEMENTATION PATH:
/// 1. Inject IFranchiseeNetworkService (all locations, territories, tiers)
/// 2. Inject INetworkPerformanceService (aggregate metrics across accounts)
/// 3. Implement network-aware allocation: national brand % + local co-op %
/// 4. Surface network health summary in DistributionRecommendation
/// </summary>
public sealed class FranchisorEngine : ISpendDistributionEngine
{
private readonly General.GeneralEngine _general;
private readonly ILogger<FranchisorEngine> _logger;
public string EngineName => "Franchisor";
public FranchisorEngine(General.GeneralEngine general, ILogger<FranchisorEngine> logger)
{
_general = general;
_logger = logger;
}
public async Task<SpendDistributionResponse> RecommendAsync(
SpendDistributionRequest request, CancellationToken ct)
{
_logger.LogInformation(
"[FranchisorEngine] Stub — delegating to GeneralEngine | Budget={Budget}",
request.MonthlyBudget);
var response = await _general.RecommendAsync(request, ct);
response.Metadata.Engine = EngineName;
// TODO: Replace with network-aware allocation once
// IFranchiseeNetworkService is implemented.
return response;
}
}

View File

@@ -0,0 +1,478 @@
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;
}

View File

@@ -0,0 +1,35 @@
using IntelligenceApi.Models;
namespace IntelligenceApi.Engines;
/// <summary>
/// Contract for all spend distribution engines.
///
/// Each engine encapsulates the logic for recommending how a client should
/// distribute their ad budget across providers. Engines vary by client category:
///
/// General — rules-based scoring (default, free tier)
/// Franchisee — location-aware, franchise-specific signals (premium)
/// Franchisor — network-wide co-op budget management (premium)
/// FoodFranchisee — geo density, competitor proximity, demographics (AI-driven)
///
/// The contract is intentionally narrow: one method in, one model out.
/// Each engine can call external APIs, ML models, or run local logic —
/// the caller doesn't need to know which.
/// </summary>
public interface ISpendDistributionEngine
{
/// <summary>
/// Human-readable name for logging, metadata, and billing attribution.
/// e.g. "General", "Franchisee", "FoodFranchisee"
/// </summary>
string EngineName { get; }
/// <summary>
/// Generate a spend distribution recommendation for the given request.
/// Must never throw — return a valid response with reduced confidence on errors.
/// </summary>
Task<SpendDistributionResponse> RecommendAsync(
SpendDistributionRequest request,
CancellationToken ct);
}