215 lines
8.1 KiB
C#
215 lines
8.1 KiB
C#
using Gateway.Models;
|
|
using System.Text.Json;
|
|
|
|
namespace Gateway.Services;
|
|
|
|
/// <summary>
|
|
/// HTTP client for IntelligenceApi — the spend distribution engine container.
|
|
///
|
|
/// The Gateway injects clientCategory from ClientContext and provider config
|
|
/// before forwarding requests. The client portal never calls IntelligenceApi
|
|
/// directly; all routing goes through the Gateway.
|
|
///
|
|
/// FALLBACK: If IntelligenceApi is unreachable, ForecastController falls back
|
|
/// to the local ForecastService (identical to the General engine output).
|
|
/// This means a container restart or deployment never breaks the wizard.
|
|
/// </summary>
|
|
public sealed class IntelligenceApiClient
|
|
{
|
|
private readonly IHttpClientFactory _http;
|
|
private readonly IConfiguration _cfg;
|
|
private readonly ILogger<IntelligenceApiClient> _logger;
|
|
|
|
private static readonly JsonSerializerOptions _jsonOpts =
|
|
new(JsonSerializerDefaults.Web);
|
|
|
|
public IntelligenceApiClient(
|
|
IHttpClientFactory http,
|
|
IConfiguration cfg,
|
|
ILogger<IntelligenceApiClient> logger)
|
|
{
|
|
_http = http;
|
|
_cfg = cfg;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forward raw census data for a ZCTA to the Intelligence container for
|
|
/// market analysis derivation (age chips, income tiers, insight strings).
|
|
/// Returns the raw JSON response string, or null if the container is
|
|
/// unreachable — caller falls back to returning raw census data.
|
|
/// </summary>
|
|
public async Task<string?> GetDemographicAnalysisAsync(
|
|
string zcta,
|
|
JsonElement censusData,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
|
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
|
|
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
|
{
|
|
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping demographics analysis");
|
|
return null;
|
|
}
|
|
|
|
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
|
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
|
|
|
var payload = JsonSerializer.Serialize(new
|
|
{
|
|
zcta,
|
|
census = censusData
|
|
}, _jsonOpts);
|
|
|
|
try
|
|
{
|
|
var client = _http.CreateClient();
|
|
using var msg = new HttpRequestMessage(
|
|
HttpMethod.Post,
|
|
$"{baseUrl.TrimEnd('/')}/api/demographics/analyze");
|
|
|
|
if (!string.IsNullOrWhiteSpace(internalKey))
|
|
msg.Headers.Add("X-Internal-Key", internalKey);
|
|
|
|
msg.Content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
|
|
|
|
_logger.LogInformation("[IntelligenceApiClient] Demographics analysis | ZCTA={Zcta}", zcta);
|
|
|
|
using var resp = await client.SendAsync(msg, ct);
|
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("[IntelligenceApiClient] Demographics non-success {Status}", (int)resp.StatusCode);
|
|
return null;
|
|
}
|
|
|
|
return body;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
_logger.LogWarning("[IntelligenceApiClient] Demographics analysis timed out | ZCTA={Zcta}", zcta);
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[IntelligenceApiClient] Demographics analysis failed | ZCTA={Zcta}", zcta);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Forward a channel forecast request to IntelligenceApi with
|
|
/// clientCategory injected. Returns null if the service is unreachable
|
|
/// or returns an error — caller should fall back to ForecastService.
|
|
/// </summary>
|
|
public async Task<ChannelForecastResponse?> GetSpendDistributionAsync(
|
|
ChannelForecastRequest request,
|
|
string? clientCategory,
|
|
CancellationToken ct)
|
|
{
|
|
var baseUrl = _cfg["INTELLIGENCE_API_URL"]
|
|
?? Environment.GetEnvironmentVariable("INTELLIGENCE_API_URL");
|
|
|
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
|
{
|
|
_logger.LogDebug("[IntelligenceApiClient] INTELLIGENCE_API_URL not configured — skipping");
|
|
return null;
|
|
}
|
|
|
|
var internalKey = _cfg["INTELLIGENCE_INTERNAL_KEY"]
|
|
?? Environment.GetEnvironmentVariable("INTELLIGENCE_INTERNAL_KEY");
|
|
|
|
// Build the IntelligenceApi request — forward everything from the
|
|
// original wizard request, plus inject clientCategory and provider config
|
|
var intelligenceRequest = new
|
|
{
|
|
clientCategory = clientCategory ?? "General",
|
|
objective = request.Objective,
|
|
businessCategory = request.BusinessCategory,
|
|
keywords = request.Keywords,
|
|
geoTargeting = request.GeoTargeting,
|
|
audience = request.Audience,
|
|
monthlyBudget = request.MonthlyBudget,
|
|
channels = request.Channels,
|
|
|
|
// Forward provider URLs so the engine can call providers directly
|
|
providerUrls = new Dictionary<string, string>
|
|
{
|
|
["google_ads"] = _cfg["GOOGLE_PROVIDER_URL"]
|
|
?? Environment.GetEnvironmentVariable("GOOGLE_PROVIDER_URL") ?? ""
|
|
},
|
|
internalKeys = new Dictionary<string, string>
|
|
{
|
|
["google_ads"] = _cfg["GOOGLE_INTERNAL_KEY"]
|
|
?? Environment.GetEnvironmentVariable("GOOGLE_INTERNAL_KEY") ?? ""
|
|
}
|
|
};
|
|
|
|
try
|
|
{
|
|
var client = _http.CreateClient();
|
|
using var msg = new HttpRequestMessage(
|
|
HttpMethod.Post,
|
|
$"{baseUrl.TrimEnd('/')}/api/spend-distribution");
|
|
|
|
if (!string.IsNullOrWhiteSpace(internalKey))
|
|
msg.Headers.Add("X-Internal-Key", internalKey);
|
|
|
|
msg.Content = new StringContent(
|
|
JsonSerializer.Serialize(intelligenceRequest, _jsonOpts),
|
|
System.Text.Encoding.UTF8, "application/json");
|
|
|
|
_logger.LogInformation(
|
|
"[IntelligenceApiClient] Calling engine | Category={Category} Budget={Budget}",
|
|
clientCategory, request.MonthlyBudget);
|
|
|
|
using var resp = await client.SendAsync(msg, ct);
|
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
|
|
|
if (!resp.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning(
|
|
"[IntelligenceApiClient] Non-success {Status}: {Body}",
|
|
(int)resp.StatusCode, body[..Math.Min(body.Length, 200)]);
|
|
return null;
|
|
}
|
|
|
|
// Map IntelligenceApi response shape → Gateway ChannelForecastResponse
|
|
// The shapes are intentionally aligned so this is a straight deserialize.
|
|
using var doc = JsonDocument.Parse(body);
|
|
var root = doc.RootElement;
|
|
|
|
if (!root.TryGetProperty("ok", out var okProp) || !okProp.GetBoolean())
|
|
{
|
|
_logger.LogWarning("[IntelligenceApiClient] Engine returned ok=false");
|
|
return null;
|
|
}
|
|
|
|
// Re-serialize then deserialize into the Gateway model
|
|
// (avoids a hard dependency on IntelligenceApi model types in Gateway)
|
|
var result = JsonSerializer.Deserialize<ChannelForecastResponse>(body, _jsonOpts);
|
|
|
|
_logger.LogInformation(
|
|
"[IntelligenceApiClient] OK | Engine={Engine} Channels={N}",
|
|
root.TryGetProperty("metadata", out var meta)
|
|
&& meta.TryGetProperty("engine", out var eng)
|
|
? eng.GetString() : "?",
|
|
result?.Channels?.Count ?? 0);
|
|
|
|
return result;
|
|
}
|
|
catch (TaskCanceledException)
|
|
{
|
|
_logger.LogWarning("[IntelligenceApiClient] Request timed out");
|
|
return null;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[IntelligenceApiClient] Request failed");
|
|
return null;
|
|
}
|
|
}
|
|
}
|