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}"; } }