diff --git a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs index cdaae80..28d7d47 100644 --- a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs +++ b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs @@ -20,7 +20,8 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService private readonly ILogger _logger; private const string Model = "claude-sonnet-4-6"; - private const int BatchSize = 10; // keep responses predictably short; 10 items × ~120 tokens ≈ 1200 output tokens + private const int BatchSize = 25; // 25 items × ~80 tokens (with word limits) ≈ 2000 output tokens, well within 8192 + private const int MaxConcurrentBatches = 3; // cap parallel API calls to stay within rate limits private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; @@ -98,26 +99,42 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService var client = new AnthropicClient(apiKey); var systemPrompt = BuildSystemPrompt(costs); - var results = new List(); - // Process items in batches of BatchSize - for (int batchStart = 0; batchStart < items.Count; batchStart += BatchSize) + // 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) => { - cancellationToken.ThrowIfCancellationRequested(); + await semaphore.WaitAsync(cancellationToken); + try + { + _logger.LogInformation("Starting price check batch {Index}/{Total} ({Count} items)", + index + 1, batches.Count, batch.Count); + return await AnalyzeBatchAsync(client, systemPrompt, batch); + } + finally + { + semaphore.Release(); + } + }).ToList(); - var batch = items.Skip(batchStart).Take(BatchSize).ToList(); - var batchResults = await AnalyzeBatchAsync(client, systemPrompt, batch, costs); - results.AddRange(batchResults); - } + var batchResults = await Task.WhenAll(batchTasks); - return results; + // 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, - ShopOperatingCostSummary costs) + List batch) { var userPrompt = BuildUserPrompt(batch); diff --git a/src/PowderCoating.Web/wwwroot/js/catalog-price-check.js b/src/PowderCoating.Web/wwwroot/js/catalog-price-check.js index 6d994c1..0962b64 100644 --- a/src/PowderCoating.Web/wwwroot/js/catalog-price-check.js +++ b/src/PowderCoating.Web/wwwroot/js/catalog-price-check.js @@ -12,8 +12,10 @@ // Estimate total seconds based on item count (roughly 12s per batch of 25, min 15s). function estimateDuration(itemCount) { - var batches = Math.max(1, Math.ceil(itemCount / 10)); - return Math.max(15, batches * 10); + // 25 items/batch, up to 3 concurrent — wall time ≈ ceil(batches/3) × 12s + var batches = Math.max(1, Math.ceil(itemCount / 25)); + var waves = Math.ceil(batches / 3); + return Math.max(15, waves * 12); } // Messages keyed to approximate progress milestones (0–100).