Initial import into Gitea
This commit is contained in:
48
IntelligenceApi/Controllers/DemographicsController.cs
Normal file
48
IntelligenceApi/Controllers/DemographicsController.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Demographics analysis endpoint.
|
||||
///
|
||||
/// Called exclusively by the Gateway's IntelligenceApiClient after it fetches
|
||||
/// raw census data from the database. This container derives audience
|
||||
/// recommendations from the raw data — age chips, income tiers, insights.
|
||||
///
|
||||
/// POST /api/demographics/analyze
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/demographics")]
|
||||
public sealed class DemographicsController : ControllerBase
|
||||
{
|
||||
private readonly DemographicsAnalyzer _analyzer;
|
||||
private readonly ILogger<DemographicsController> _log;
|
||||
|
||||
public DemographicsController(DemographicsAnalyzer analyzer, ILogger<DemographicsController> log)
|
||||
{
|
||||
_analyzer = analyzer;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost("analyze")]
|
||||
public IActionResult Analyze([FromBody] DemographicAnalysisRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.Zcta))
|
||||
return BadRequest(new { ok = false, error = "zcta and census data are required" });
|
||||
|
||||
_log.LogInformation("[Demographics] Analyze | ZCTA={Zcta}", request.Zcta);
|
||||
|
||||
try
|
||||
{
|
||||
var result = _analyzer.Analyze(request);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Demographics] Analysis error | ZCTA={Zcta}", request.Zcta);
|
||||
return StatusCode(500, new { ok = false, error = "Demographics analysis error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
122
IntelligenceApi/Controllers/InternalController.cs
Normal file
122
IntelligenceApi/Controllers/InternalController.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Internal execution endpoint — the single entry point for Gateway-routed requests.
|
||||
///
|
||||
/// Called exclusively by the Gateway's ExecutionService via:
|
||||
/// POST /internal/execute
|
||||
/// Headers: X-Internal-Key, X-Request-Id
|
||||
/// Body: { "operation": "Ping" | "SpendDistribution", "payload": { ... } }
|
||||
///
|
||||
/// Operations:
|
||||
/// Ping — liveness check, no payload required
|
||||
/// SpendDistribution — routes to EngineRouter; payload maps to SpendDistributionRequest
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("internal/execute")]
|
||||
public sealed class InternalController : ControllerBase
|
||||
{
|
||||
private readonly EngineRouter _router;
|
||||
private readonly ILogger<InternalController> _log;
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOpts =
|
||||
new(JsonSerializerDefaults.Web);
|
||||
|
||||
public InternalController(EngineRouter router, ILogger<InternalController> log)
|
||||
{
|
||||
_router = router;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Execute(
|
||||
[FromBody] InternalExecuteRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body required" });
|
||||
|
||||
var requestId = request.RequestId ?? HttpContext.Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N");
|
||||
var operation = (request.Operation ?? "").Trim();
|
||||
|
||||
_log.LogInformation(
|
||||
"[Internal] Operation={Operation} RequestId={RequestId}",
|
||||
operation, requestId);
|
||||
|
||||
return operation.ToLowerInvariant() switch
|
||||
{
|
||||
"ping" => Ok(new { ok = true, requestId, service = "IntelligenceApi", timestamp = DateTimeOffset.UtcNow }),
|
||||
"spenddistribution" => await SpendDistribution(request.Payload, requestId, ct),
|
||||
_ => BadRequest(new { ok = false, requestId, error = $"Unknown operation: '{operation}'" })
|
||||
};
|
||||
}
|
||||
|
||||
// ── Spend Distribution ───────────────────────────────────────────────────
|
||||
|
||||
private async Task<IActionResult> SpendDistribution(
|
||||
JsonElement? payload,
|
||||
string requestId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (payload == null || payload.Value.ValueKind == JsonValueKind.Null)
|
||||
return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" });
|
||||
|
||||
SpendDistributionRequest? distRequest;
|
||||
try
|
||||
{
|
||||
distRequest = JsonSerializer.Deserialize<SpendDistributionRequest>(
|
||||
payload.Value.GetRawText(), _jsonOpts);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[Internal] Payload deserialize failed | RequestId={RequestId} Error={Error}",
|
||||
requestId, ex.Message);
|
||||
return BadRequest(new { ok = false, requestId, error = "Invalid payload shape for SpendDistribution" });
|
||||
}
|
||||
|
||||
if (distRequest == null)
|
||||
return BadRequest(new { ok = false, requestId, error = "Payload required for SpendDistribution" });
|
||||
|
||||
if (distRequest.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, requestId, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (distRequest.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, requestId, error = "At least one keyword is required" });
|
||||
|
||||
try
|
||||
{
|
||||
var engine = _router.Resolve(distRequest.ClientCategory);
|
||||
var response = await engine.RecommendAsync(distRequest, ct);
|
||||
|
||||
_log.LogInformation(
|
||||
"[Internal] SpendDistribution OK | RequestId={RequestId} Engine={Engine} Channels={N}",
|
||||
requestId, response.Metadata.Engine, response.Channels.Count);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[Internal] SpendDistribution error | RequestId={RequestId}", requestId);
|
||||
return StatusCode(500, new { ok = false, requestId, error = "Intelligence service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request model ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Shape sent by Gateway's ExecutionService to every provider container.
|
||||
/// Matches the object built in ExecutionService.BuildProviderRequest().
|
||||
/// </summary>
|
||||
public sealed class InternalExecuteRequest
|
||||
{
|
||||
public string? Operation { get; set; }
|
||||
public string? RequestId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public JsonElement? Payload { get; set; }
|
||||
}
|
||||
67
IntelligenceApi/Controllers/SpendDistributionController.cs
Normal file
67
IntelligenceApi/Controllers/SpendDistributionController.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace IntelligenceApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Spend distribution endpoint — the single public surface of IntelligenceApi.
|
||||
///
|
||||
/// Called exclusively by the Gateway (never directly by the client portal).
|
||||
/// The Gateway injects clientCategory from ClientContext before forwarding.
|
||||
///
|
||||
/// POST /api/spend-distribution
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/spend-distribution")]
|
||||
public sealed class SpendDistributionController : ControllerBase
|
||||
{
|
||||
private readonly EngineRouter _router;
|
||||
private readonly ILogger<SpendDistributionController> _log;
|
||||
|
||||
public SpendDistributionController(EngineRouter router, ILogger<SpendDistributionController> log)
|
||||
{
|
||||
_router = router;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a spend distribution recommendation.
|
||||
/// clientCategory in the request body determines which engine runs.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Recommend(
|
||||
[FromBody] SpendDistributionRequest? request,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (request == null)
|
||||
return BadRequest(new { ok = false, error = "Request body required" });
|
||||
|
||||
if (request.MonthlyBudget <= 0)
|
||||
return BadRequest(new { ok = false, error = "monthlyBudget must be greater than zero" });
|
||||
|
||||
if (request.Keywords.Count == 0)
|
||||
return BadRequest(new { ok = false, error = "At least one keyword is required" });
|
||||
|
||||
_log.LogInformation(
|
||||
"[SpendDistribution] Request | Category={Category} Budget={Budget} Objective={Obj}",
|
||||
request.ClientCategory, request.MonthlyBudget, request.Objective);
|
||||
|
||||
try
|
||||
{
|
||||
var engine = _router.Resolve(request.ClientCategory);
|
||||
var response = await engine.RecommendAsync(request, ct);
|
||||
|
||||
_log.LogInformation(
|
||||
"[SpendDistribution] OK | Engine={Engine} Channels={N}",
|
||||
response.Metadata.Engine, response.Channels.Count);
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[SpendDistribution] Unhandled error");
|
||||
return StatusCode(500, new { ok = false, error = "Intelligence service error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
91
IntelligenceApi/Engines/DemographicsAnalyzer.cs
Normal file
91
IntelligenceApi/Engines/DemographicsAnalyzer.cs
Normal 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 (18–34)",
|
||||
"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
|
||||
};
|
||||
}
|
||||
}
|
||||
75
IntelligenceApi/Engines/EngineRouter.cs
Normal file
75
IntelligenceApi/Engines/EngineRouter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
59
IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs
Normal file
59
IntelligenceApi/Engines/Franchisee/FranchiseeEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
58
IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs
Normal file
58
IntelligenceApi/Engines/Franchisor/FranchisorEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
478
IntelligenceApi/Engines/General/GeneralEngine.cs
Normal file
478
IntelligenceApi/Engines/General/GeneralEngine.cs
Normal 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;
|
||||
}
|
||||
35
IntelligenceApi/Engines/ISpendDistributionEngine.cs
Normal file
35
IntelligenceApi/Engines/ISpendDistributionEngine.cs
Normal 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);
|
||||
}
|
||||
6
IntelligenceApi/IntelligenceAPI.http
Normal file
6
IntelligenceApi/IntelligenceAPI.http
Normal file
@@ -0,0 +1,6 @@
|
||||
@IntelligenceAPI_HostAddress = http://localhost:5271
|
||||
|
||||
GET {{IntelligenceAPI_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
10
IntelligenceApi/IntelligenceApi.csproj
Normal file
10
IntelligenceApi/IntelligenceApi.csproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>IntelligenceApi</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
75
IntelligenceApi/Models/DemographicsModels.cs
Normal file
75
IntelligenceApi/Models/DemographicsModels.cs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace IntelligenceApi.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Gateway → IntelligenceApi
|
||||
// Gateway sends raw census data fetched from DB,
|
||||
// plus the ZCTA for context.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class DemographicAnalysisRequest
|
||||
{
|
||||
/// <summary>5-digit ZIP code / ZCTA</summary>
|
||||
public string Zcta { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Raw census row from spDemographics.
|
||||
/// All numeric fields; nulls treated as zero.
|
||||
/// </summary>
|
||||
public CensusData Census { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CensusData
|
||||
{
|
||||
public int TotalPopulation { get; set; }
|
||||
public int TotalHouseholds { get; set; }
|
||||
public int MedianIncome { get; set; }
|
||||
public int MedianHomeValue { get; set; }
|
||||
public decimal Pct18to24 { get; set; }
|
||||
public decimal Pct25to34 { get; set; }
|
||||
public decimal Pct35to44 { get; set; }
|
||||
public decimal Pct45to54 { get; set; }
|
||||
public decimal Pct55to64 { get; set; }
|
||||
public decimal Pct65plus { get; set; }
|
||||
public decimal PctBachelorPlus { get; set; }
|
||||
public decimal PctOwnerOccupied { get; set; }
|
||||
public decimal PctRenterOccupied { get; set; }
|
||||
public decimal PctFamilyHouseholds { get; set; }
|
||||
public decimal PctLivingAlone { get; set; }
|
||||
public decimal PctHispanic { get; set; }
|
||||
public decimal PctAsian { get; set; }
|
||||
public decimal PctBlack { get; set; }
|
||||
public decimal PctRemoteWork { get; set; }
|
||||
public decimal PctPublicTransit { get; set; }
|
||||
public decimal UnemploymentRate { get; set; }
|
||||
public decimal PctIncomeUnder30k { get; set; }
|
||||
public decimal PctIncome30kTo75k { get; set; }
|
||||
public decimal PctIncome75kTo150k { get; set; }
|
||||
public decimal PctIncome150kPlus { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: IntelligenceApi → Gateway → Client
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class DemographicAnalysisResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Zcta { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Raw census metrics passed through for display</summary>
|
||||
public CensusData Census { get; set; } = new();
|
||||
|
||||
/// <summary>Derived recommendations for wizard chip auto-population</summary>
|
||||
public AudienceRecommendations Recommendations { get; set; } = new();
|
||||
|
||||
/// <summary>Human-readable summary strings for the insight bar</summary>
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class AudienceRecommendations
|
||||
{
|
||||
public List<string> AgeRanges { get; set; } = new();
|
||||
public List<string> Incomes { get; set; } = new();
|
||||
public string AgeSkew { get; set; } = "balanced";
|
||||
public string MarketScope { get; set; } = "local";
|
||||
}
|
||||
117
IntelligenceApi/Models/SpendDistributionModels.cs
Normal file
117
IntelligenceApi/Models/SpendDistributionModels.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
namespace IntelligenceApi.Models;
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Request: Gateway → IntelligenceApi
|
||||
// Gateway injects clientCategory from ClientContext
|
||||
// before forwarding the wizard's forecast request.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class SpendDistributionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Client category — the primary engine selector.
|
||||
/// Values: General | Franchisee | Franchisor | FoodFranchisee | etc.
|
||||
/// Injected by the Gateway from ClientContext.ClientCategory.
|
||||
/// </summary>
|
||||
public string ClientCategory { get; set; } = "General";
|
||||
|
||||
/// <summary>Advertising objective: awareness, traffic, leads, sales</summary>
|
||||
public string Objective { get; set; } = "traffic";
|
||||
|
||||
/// <summary>Business category from wizard Step 1 (industry)</summary>
|
||||
public string? BusinessCategory { get; set; }
|
||||
|
||||
/// <summary>Keywords from URL analysis</summary>
|
||||
public List<string> Keywords { get; set; } = new();
|
||||
|
||||
/// <summary>Geo targeting from audience step</summary>
|
||||
public GeoTargeting? GeoTargeting { get; set; }
|
||||
|
||||
/// <summary>Audience parameters</summary>
|
||||
public AudienceParams? Audience { get; set; }
|
||||
|
||||
/// <summary>Monthly budget in whole dollars</summary>
|
||||
public decimal MonthlyBudget { get; set; }
|
||||
|
||||
/// <summary>Channels to estimate</summary>
|
||||
public List<string>? Channels { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provider base URLs forwarded from Gateway config.
|
||||
/// Allows engines to call providers without needing their own config.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? ProviderUrls { get; set; }
|
||||
|
||||
/// <summary>Internal API keys forwarded from Gateway config.</summary>
|
||||
public Dictionary<string, string>? InternalKeys { get; set; }
|
||||
}
|
||||
|
||||
public sealed class GeoTargeting
|
||||
{
|
||||
public List<string>? ZipCodes { get; set; }
|
||||
public double? RadiusMiles { get; set; }
|
||||
public List<long>? GeoTargetIds { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AudienceParams
|
||||
{
|
||||
public int? AgeMin { get; set; }
|
||||
public int? AgeMax { get; set; }
|
||||
public List<string>? Genders { get; set; }
|
||||
public List<string>? Interests { get; set; }
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════
|
||||
// Response: IntelligenceApi → Gateway → Client
|
||||
// Identical shape to ChannelForecastResponse in
|
||||
// the Gateway so no client changes are needed.
|
||||
// ════════════════════════════════════════════════
|
||||
|
||||
public sealed class SpendDistributionResponse
|
||||
{
|
||||
public bool Ok { get; set; } = true;
|
||||
public string Objective { get; set; } = string.Empty;
|
||||
public decimal TotalBudget { get; set; }
|
||||
public List<ChannelAllocation> Channels { get; set; } = new();
|
||||
public DistributionRecommendation? Recommendation { get; set; }
|
||||
public DistributionMeta Metadata { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ChannelAllocation
|
||||
{
|
||||
public string Provider { get; set; } = string.Empty;
|
||||
public int AllocationPercent { get; set; }
|
||||
public decimal AllocatedBudget { get; set; }
|
||||
public AllocationMetrics Estimates { get; set; } = new();
|
||||
public double EfficiencyScore { get; set; }
|
||||
public string StrengthLabel { get; set; } = string.Empty;
|
||||
public string Confidence { get; set; } = "none";
|
||||
public string DataSource { get; set; } = "none";
|
||||
}
|
||||
|
||||
public sealed class AllocationMetrics
|
||||
{
|
||||
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 decimal? EstimatedCpa { get; set; }
|
||||
public double Ctr { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DistributionRecommendation
|
||||
{
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Highlights { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class DistributionMeta
|
||||
{
|
||||
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string ForecastPeriod { get; set; } = "30 days";
|
||||
|
||||
/// <summary>Which engine handled this request — useful for debugging and billing.</summary>
|
||||
public string Engine { get; set; } = "General";
|
||||
}
|
||||
105
IntelligenceApi/Program.cs
Normal file
105
IntelligenceApi/Program.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using IntelligenceApi.Engines;
|
||||
using IntelligenceApi.Engines.Franchisee;
|
||||
using IntelligenceApi.Engines.Franchisor;
|
||||
using IntelligenceApi.Engines.General;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --------------------
|
||||
// Container-friendly HTTP binding
|
||||
// --------------------
|
||||
var port = Environment.GetEnvironmentVariable("PORT") ?? "8081";
|
||||
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
|
||||
|
||||
// --------------------
|
||||
// Services
|
||||
// --------------------
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
// ── Engines ──
|
||||
// General is a singleton — stateless, no DB dependency, safe to share.
|
||||
// Franchisee and Franchisor delegate to General; register as singletons too.
|
||||
// When real data services are added, switch to Scoped.
|
||||
builder.Services.AddSingleton<GeneralEngine>();
|
||||
builder.Services.AddSingleton<FranchiseeEngine>();
|
||||
builder.Services.AddSingleton<FranchisorEngine>();
|
||||
builder.Services.AddSingleton<EngineRouter>();
|
||||
builder.Services.AddSingleton<DemographicsAnalyzer>();
|
||||
|
||||
// --------------------
|
||||
// Security: internal-only access
|
||||
// Simple shared key check — requests must include X-Internal-Key header
|
||||
// matching the INTELLIGENCE_INTERNAL_KEY environment variable.
|
||||
// The Gateway sets this key; the container is not publicly exposed.
|
||||
// --------------------
|
||||
var internalKey = builder.Configuration["INTELLIGENCE_INTERNAL_KEY"]
|
||||
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Health check (no auth — used by ACA liveness probe)
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
service = "IntelligenceApi",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
}));
|
||||
|
||||
app.MapGet("/", () => Results.Ok(new
|
||||
{
|
||||
service = "IntelligenceApi",
|
||||
version = "1.0.0",
|
||||
status = "Spend distribution engine running"
|
||||
}));
|
||||
|
||||
// Internal key middleware — validates all /api/* requests
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value ?? "";
|
||||
|
||||
// Pass health and root through unauthenticated
|
||||
if (path == "/" || path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// In development, skip key check if not configured
|
||||
if (string.IsNullOrWhiteSpace(internalKey))
|
||||
{
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = 503;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
ok = false,
|
||||
error = "Service not configured (missing INTELLIGENCE_INTERNAL_KEY)"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.Request.Headers.TryGetValue("X-Internal-Key", out var key)
|
||||
|| key.FirstOrDefault() != internalKey)
|
||||
{
|
||||
context.Response.StatusCode = 401;
|
||||
await context.Response.WriteAsJsonAsync(new { ok = false, error = "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
Console.WriteLine($"[IntelligenceApi] Starting on port {port}");
|
||||
Console.WriteLine($"[IntelligenceApi] Internal key configured: {!string.IsNullOrWhiteSpace(internalKey)}");
|
||||
|
||||
app.Run();
|
||||
52
IntelligenceApi/Properties/launchSettings.json
Normal file
52
IntelligenceApi/Properties/launchSettings.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "http://localhost:5271"
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"dotnetRunMessages": true,
|
||||
"applicationUrl": "https://localhost:7044;http://localhost:5271"
|
||||
},
|
||||
"IIS Express": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"Container (.NET SDK)": {
|
||||
"commandName": "SdkContainer",
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/swagger",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_HTTPS_PORTS": "8081",
|
||||
"ASPNETCORE_HTTP_PORTS": "8080"
|
||||
},
|
||||
"publishAllPorts": true,
|
||||
"useSSL": true
|
||||
}
|
||||
},
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"iisSettings": {
|
||||
"windowsAuthentication": false,
|
||||
"anonymousAuthentication": true,
|
||||
"iisExpress": {
|
||||
"applicationUrl": "http://localhost:50991",
|
||||
"sslPort": 44357
|
||||
}
|
||||
}
|
||||
}
|
||||
9
IntelligenceApi/appsettings.Development.json
Normal file
9
IntelligenceApi/appsettings.Development.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"IntelligenceApi": "Debug"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
IntelligenceApi/appsettings.json
Normal file
9
IntelligenceApi/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user