using System.Net.Http;
using System.Text;
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
///
/// Sends catalog items to Claude in batches of 25 and collects per-item price verdicts.
/// Each batch produces one Claude call so large catalogs stay within the model's context
/// limits. Results across all batches are merged into a single flat list before returning.
///
public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService
{
private readonly IConfiguration _config;
private readonly ILogger _logger;
private const string Model = "claude-haiku-4-5-20251001";
private const int BatchSize = 25;
private const int MaxConcurrentBatches = 2;
private const int RateLimitRetrySeconds = 65;
private const int MinBatchIntervalSeconds = 20; // proactive pacing: ~3 batches/min × ~2k tokens = ~6k TPM, under the 8k limit
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
public AiCatalogPriceCheckService(IConfiguration config, ILogger logger)
{
_config = config;
_logger = logger;
}
private string? GetApiKey()
{
var key = _config["AI:Anthropic:ApiKey"];
return string.IsNullOrWhiteSpace(key) || key.StartsWith("your-") ? null : key;
}
///
/// Extracts a JSON array from Claude's response, handling three common failure modes:
/// (1) ```json ... ``` fences wrapping the array,
/// (2) prose text before or after the JSON array,
/// (3) truncated responses where the closing ] is missing — in that case we close any
/// open string and append ]} to produce a parseable (though incomplete) array so
/// we recover whatever items Claude did finish.
///
private static string ExtractJsonArray(string text)
{
var trimmed = text.Trim();
// Strip code fences
if (trimmed.StartsWith("```"))
{
var firstNewline = trimmed.IndexOf('\n');
if (firstNewline >= 0) trimmed = trimmed[(firstNewline + 1)..];
if (trimmed.EndsWith("```")) trimmed = trimmed[..^3];
trimmed = trimmed.Trim();
}
// Find the outermost [ ... ] even when Claude adds prose around it
var arrayStart = trimmed.IndexOf('[');
if (arrayStart < 0) return "[]";
trimmed = trimmed[arrayStart..];
var arrayEnd = trimmed.LastIndexOf(']');
if (arrayEnd >= 0)
return trimmed[..(arrayEnd + 1)];
// No closing bracket — response was truncated. Patch it so we can recover
// whatever complete objects Claude did return.
// Strategy: find the last complete }, and close the array after it.
var lastComplete = trimmed.LastIndexOf("},");
if (lastComplete < 0) lastComplete = trimmed.LastIndexOf('}');
if (lastComplete >= 0)
return trimmed[..(lastComplete + 1)] + "]";
return "[]";
}
///
/// Sends a message to Claude with up to 3 attempts. On a rate-limit 429, waits
/// RateLimitRetrySeconds before retrying so the per-minute token window can reset.
///
private async Task SendAsync(AnthropicClient client, MessageParameters parameters)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, cts.Token);
}
catch (HttpRequestException ex) when (attempt < maxAttempts && ex.Message.Contains("rate_limit_error"))
{
_logger.LogWarning("Rate limit hit (attempt {Attempt}/{Max}), waiting {Seconds}s before retry",
attempt, maxAttempts, RateLimitRetrySeconds);
await Task.Delay(TimeSpan.FromSeconds(RateLimitRetrySeconds));
}
}
// Final attempt — let any exception propagate to the batch error handler
using var finalCts = new CancellationTokenSource(TimeSpan.FromSeconds(90));
return await client.Messages.GetClaudeMessageAsync(parameters, finalCts.Token);
}
///
public async Task> AnalyzeAsync(
List items,
ShopOperatingCostSummary costs,
CancellationToken cancellationToken = default)
{
var apiKey = GetApiKey();
if (apiKey == null)
{
_logger.LogWarning("AI Catalog Price Check called but Anthropic API key is not configured.");
return new List();
}
var client = new AnthropicClient(apiKey);
var systemPrompt = BuildSystemPrompt(costs);
// Split into independent batches upfront
var batches = Enumerable.Range(0, (int)Math.Ceiling(items.Count / (double)BatchSize))
.Select(i => items.Skip(i * BatchSize).Take(BatchSize).ToList())
.ToList();
// Run up to MaxConcurrentBatches in parallel. Each batch is an independent API call
// with its own fresh MessageParameters — no shared state, no growing context.
var semaphore = new SemaphoreSlim(MaxConcurrentBatches, MaxConcurrentBatches);
var batchTasks = batches.Select(async (batch, index) =>
{
await semaphore.WaitAsync(cancellationToken);
try
{
_logger.LogInformation("Starting price check batch {Index}/{Total} ({Count} items)",
index + 1, batches.Count, batch.Count);
var sw = System.Diagnostics.Stopwatch.StartNew();
var result = await AnalyzeBatchAsync(client, systemPrompt, batch);
// Pace output token rate: hold the slot until MinBatchIntervalSeconds has elapsed
// so we stay under the per-minute output token limit without relying solely on retries.
var pad = (int)(MinBatchIntervalSeconds * 1000 - sw.ElapsedMilliseconds);
if (pad > 0) await Task.Delay(pad, cancellationToken);
return result;
}
finally
{
semaphore.Release();
}
}).ToList();
var batchResults = await Task.WhenAll(batchTasks);
// Preserve original catalog order
return batches.Zip(batchResults, (batch, results) => results)
.SelectMany(r => r)
.ToList();
}
private async Task> AnalyzeBatchAsync(
AnthropicClient client,
string systemPrompt,
List batch)
{
var userPrompt = BuildUserPrompt(batch);
var parameters = new MessageParameters
{
Model = Model,
MaxTokens = 8192,
SystemMessage = systemPrompt,
Messages = new List
{
new() { Role = RoleType.User, Content = new List { new TextContent { Text = userPrompt } } }
}
};
var raw = string.Empty;
try
{
var response = await SendAsync(client, parameters);
raw = response.Content.OfType().FirstOrDefault()?.Text ?? "[]";
var json = ExtractJsonArray(raw);
var claudeItems = JsonSerializer.Deserialize>(json, JsonOpts) ?? new();
return claudeItems.Select(ci =>
{
var source = batch.FirstOrDefault(b => b.Id == ci.catalogItemId);
return new CatalogItemPriceVerdict
{
CatalogItemId = ci.catalogItemId,
Name = source?.Name ?? $"Item #{ci.catalogItemId}",
CurrentPrice = source?.CurrentPrice ?? 0,
Assumptions = ci.assumptions,
EstimatedSqFtMin = ci.estimatedSqFtMin,
EstimatedSqFtMax = ci.estimatedSqFtMax,
EstimatedMinutesMin = ci.estimatedMinutesMin,
EstimatedMinutesMax = ci.estimatedMinutesMax,
CostFloor = ci.costFloor,
Verdict = ci.verdict,
SuggestedPriceMin = ci.suggestedPriceMin,
SuggestedPriceMax = ci.suggestedPriceMax,
Confidence = ci.confidence,
Reasoning = ci.reasoning
};
}).ToList();
}
catch (Exception ex)
{
var preview = raw.Length > 300 ? raw[..300] + "…" : raw;
_logger.LogError(ex,
"AI price check batch failed for items [{ItemIds}]. Raw response preview: {RawPreview}",
string.Join(", ", batch.Select(b => b.Id)), preview);
return batch.Select(item => new CatalogItemPriceVerdict
{
CatalogItemId = item.Id,
Name = item.Name,
CurrentPrice = item.CurrentPrice,
Verdict = "ok",
Confidence = "low",
Assumptions = "Analysis unavailable for this item.",
Reasoning = "An error occurred during analysis. Please re-run the price check."
}).ToList();
}
}
private static string BuildSystemPrompt(ShopOperatingCostSummary costs)
{
var sb = new StringBuilder();
sb.AppendLine("You are a pricing consultant for a powder coating business. Your job is to review catalog items and flag potential pricing problems against the shop's actual operating costs.");
sb.AppendLine();
sb.AppendLine("## Shop Operating Costs");
sb.AppendLine($"- Labor rate: ${costs.LaborRatePerHour:F2}/hr");
sb.AppendLine($"- Oven operating cost: ${costs.OvenCostPerHour:F2}/hr");
sb.AppendLine($"- Sandblaster cost: ${costs.SandblasterCostPerHour:F2}/hr");
sb.AppendLine($"- Coating booth cost: ${costs.CoatingBoothCostPerHour:F2}/hr");
sb.AppendLine($"- Powder material cost: ${costs.PowderCostPerSqFt:F2}/sqft");
sb.AppendLine($"- Shop supplies surcharge: {costs.ShopSuppliesRatePercent:F1}%");
if (costs.PricingMode == "margin")
sb.AppendLine($"- Target gross margin: {costs.MarkupOrMarginPercent:F1}%");
else
sb.AppendLine($"- Markup on material: {costs.MarkupOrMarginPercent:F1}%");
if (costs.ShopMinimumCharge > 0)
sb.AppendLine($"- Shop minimum charge: ${costs.ShopMinimumCharge:F2}");
if (!string.IsNullOrWhiteSpace(costs.AiContextProfile))
{
sb.AppendLine();
sb.AppendLine("## Shop Profile");
sb.AppendLine(costs.AiContextProfile);
}
sb.AppendLine();
sb.AppendLine("## Instructions");
sb.AppendLine("For each item, use industry knowledge to estimate a plausible surface area and processing time. Then compute a cost floor = (labor + equipment + material) using the shop's rates above. Compare the cost floor to the item's current price and return a verdict.");
sb.AppendLine();
sb.AppendLine("Verdict values:");
sb.AppendLine("- \"below-cost\" — price is at or below cost floor (the shop loses money)");
sb.AppendLine("- \"low\" — price is above cost floor but margin is thin (< the shop's target margin)");
sb.AppendLine("- \"high\" — price appears significantly above comparable market rates (risk of losing work)");
sb.AppendLine("- \"ok\" — price is within a reasonable range");
sb.AppendLine();
sb.AppendLine("Confidence values:");
sb.AppendLine("- \"high\" — item name clearly identifies part type and standard dimensions");
sb.AppendLine("- \"medium\" — reasonable assumptions were possible");
sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')");
sb.AppendLine();
sb.AppendLine("The \"category\" field contains the full path, e.g. \"Cerakote > Firearms\" or \"Powder Coat > Wheels\". Use this to determine the coating process — Cerakote items have a very different cost profile than standard powder coat (different equipment, cure times, and market rates). Price accordingly.");
sb.AppendLine();
sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing.");
sb.AppendLine();
sb.AppendLine("IMPORTANT: Keep responses concise to avoid truncation. Limit assumptions to 20 words max. Limit reasoning to 25 words max.");
sb.AppendLine();
sb.AppendLine("Return ONLY a JSON array — no prose, no markdown fences, nothing before or after the '['. Use this exact schema for each element:");
sb.AppendLine(@"{
""catalogItemId"": ,
""assumptions"": ""<≤20 words>"",
""estimatedSqFtMin"": ,
""estimatedSqFtMax"": ,
""estimatedMinutesMin"": ,
""estimatedMinutesMax"": ,
""costFloor"": ,
""verdict"": ""ok|low|high|below-cost"",
""suggestedPriceMin"": ,
""suggestedPriceMax"": ,
""confidence"": ""high|medium|low"",
""reasoning"": ""<≤25 words>""
}");
return sb.ToString();
}
// Local schema — mirrors the JSON shape Claude is asked to return. Kept private to
// the Infrastructure layer because it's a transport detail, not a domain concept.
private sealed class ClaudePriceCheckItem
{
public int catalogItemId { get; set; }
public string assumptions { get; set; } = string.Empty;
public decimal estimatedSqFtMin { get; set; }
public decimal estimatedSqFtMax { get; set; }
public int estimatedMinutesMin { get; set; }
public int estimatedMinutesMax { get; set; }
public decimal costFloor { get; set; }
public string verdict { get; set; } = "ok";
public decimal suggestedPriceMin { get; set; }
public decimal suggestedPriceMax { get; set; }
public string confidence { get; set; } = "medium";
public string reasoning { get; set; } = string.Empty;
}
private static string BuildUserPrompt(List batch)
{
var itemsJson = JsonSerializer.Serialize(batch.Select(item => new
{
catalogItemId = item.Id,
name = item.Name,
category = item.CategoryName,
currentPrice = item.CurrentPrice,
approximateAreaSqFt = item.ApproximateAreaSqFt,
estimatedMinutes = item.EstimatedMinutes,
requiresSandblasting = item.RequiresSandblasting,
requiresMasking = item.RequiresMasking
}), new JsonSerializerOptions { WriteIndented = false });
return $"Analyze these {batch.Count} catalog items and return the JSON verdict array:\n{itemsJson}";
}
}