From 74414c6c7144dd22ad543514aa43458284da0024 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Wed, 6 May 2026 12:27:27 -0400 Subject: [PATCH] Add AI overload retry with model fallback and consolidate wizard errors Anthropic returns overloaded_error (HTTP 529) during high-demand periods. Previously this failed immediately with a generic error. Now the service retries Sonnet once after 5s, then falls back to Haiku (a separate capacity pool) after another 3s before giving up. If all three attempts are overloaded the user sees a clear "high demand" message rather than a generic error. Non-overload errors still log at Error level. Also consolidated AI wizard error display in item-wizard.js: photo upload failures were using browser alert() while analyze failures used the inline red alert bar. All errors now go through aiShowError() so they always appear consistently as the red bar below the Analyze button. Removed the alert() fallback from aiShowError() itself. Co-Authored-By: Claude Sonnet 4.6 --- .../Services/AiQuoteService.cs | 49 ++++++++++- .../wwwroot/js/item-wizard.js | 81 ++++++++++++++----- 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/AiQuoteService.cs b/src/PowderCoating.Infrastructure/Services/AiQuoteService.cs index fb79bf0..a75d13c 100644 --- a/src/PowderCoating.Infrastructure/Services/AiQuoteService.cs +++ b/src/PowderCoating.Infrastructure/Services/AiQuoteService.cs @@ -254,8 +254,44 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum Messages = messages }; - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); - var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token); + // On overloaded_error (HTTP 529): retry Sonnet once after a short delay, then + // fall back to Haiku (separate capacity pool). If Haiku is also overloaded, give up. + // Total worst-case added latency before fallback: ~5s. + MessageResponse response; + var modelsToTry = new[] { "claude-sonnet-4-6", "claude-sonnet-4-6", "claude-haiku-4-5-20251001" }; + HttpRequestException? lastOverloadEx = null; + response = null!; + for (int attempt = 0; attempt < modelsToTry.Length; attempt++) + { + messageRequest.Model = modelsToTry[attempt]; + if (attempt > 0) + { + var delay = attempt == 1 ? TimeSpan.FromSeconds(5) : TimeSpan.FromSeconds(3); + _logger.LogWarning("Claude API overloaded on {Model} (attempt {Attempt}); retrying with {NextModel} in {Delay}s", + modelsToTry[attempt - 1], attempt, modelsToTry[attempt], delay.TotalSeconds); + await Task.Delay(delay); + } + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + try + { + response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token); + lastOverloadEx = null; + break; + } + catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error")) + { + lastOverloadEx = hex; + } + } + if (lastOverloadEx != null) + { + _logger.LogWarning(lastOverloadEx, "Claude API overloaded on all models including fallback"); + return new AiAnalyzeItemResult + { + Success = false, + ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again." + }; + } var rawText = response.FirstMessage?.Text ?? response.Content.OfType().FirstOrDefault()?.Text ?? ""; @@ -329,6 +365,15 @@ Only ask follow-up questions if truly needed — prefer to make reasonable assum ErrorMessage = "The AI service did not respond in time. Please try again." }; } + catch (HttpRequestException hex) when (hex.Message.Contains("overloaded_error")) + { + _logger.LogWarning(hex, "Claude API overloaded (outer catch — unexpected path)"); + return new AiAnalyzeItemResult + { + Success = false, + ErrorMessage = "The AI service is experiencing high demand right now. Please wait a minute and try again." + }; + } catch (Exception ex) { _logger.LogError(ex, "Error calling Claude AI for quote analysis"); diff --git a/src/PowderCoating.Web/wwwroot/js/item-wizard.js b/src/PowderCoating.Web/wwwroot/js/item-wizard.js index 510c8fa..c97549e 100644 --- a/src/PowderCoating.Web/wwwroot/js/item-wizard.js +++ b/src/PowderCoating.Web/wwwroot/js/item-wizard.js @@ -1184,10 +1184,10 @@ async function aiUploadFile(file) { aiRefreshPhotoList(); document.getElementById('ai_photoError')?.classList.add('d-none'); } else { - alert('Upload failed: ' + (result.error || 'Unknown error')); + aiShowError('Upload failed: ' + (result.error || 'Unknown error')); } } catch (err) { - alert('Upload error: ' + err.message); + aiShowError('Upload error: ' + err.message); } } @@ -1454,24 +1454,49 @@ function aiShowError(message) { el.textContent = message; el.classList.remove('d-none'); el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } else { - // Fallback if element not found - alert('AI Error: ' + message); } } // Step 3: Coating layers function renderStep3Html() { + const isSandblastOnly = !!wz.data.sandblastOnly; return ` -

- - Add one or more coating layers. The first coat uses 100% of the labor estimate; - each additional coat adds 30%. -

-
- `; +
+ + +
+
+

+ + Add one or more coating layers. The first coat uses 100% of the labor estimate; + each additional coat adds 30%. +

+
+ +
+ ${isSandblastOnly ? `
+ + No powder coating — no oven or powder costs will be applied. +
` : ''}`; +} + +function onSandblastOnlyToggle() { + const checked = document.getElementById('sandblastOnlyToggle')?.checked; + wz.data.sandblastOnly = checked; + if (checked) { + wz.data.coats = []; + // AI price was estimated with coating in mind — clear it so pricing recalculates from prep labor + if (wz.itemType === 'ai') { + wz.data.manualUnitPrice = null; + } + } + renderStep(3); } function renderCoatsList() { @@ -1893,8 +1918,9 @@ function renderStep4Html() { `; } + const isSandblastOnly = !!wz.data.sandblastOnly; const isCatalog = wz.itemType === 'product'; - const isAi = wz.itemType === 'ai'; + const isAi = wz.itemType === 'ai' && !isSandblastOnly; const includePrepCost = wz.data.includePrepCost ?? !isCatalog; // default ON for calculated, OFF for catalog const current = wz.data.prepServices || []; @@ -1917,6 +1943,12 @@ function renderStep4Html() { Select the services below for shop floor reference — they will not add to the item price. ` : ''; + const sandblastBanner = isSandblastOnly ? ` +
+ + Sandblast / Prep Only: estimated minutes will be billed as labor — no powder or oven costs. +
` : ''; + const blastOptions = blastSetupData.length > 0 ? blastSetupData.map(s => ``).join('') : ''; @@ -1960,7 +1992,7 @@ function renderStep4Html() { ? '' : `
Check each prep step needed and enter an estimated time. Labor cost is added to this item's total.
`; - return `${catalogBanner}${aiBanner}${hint}${rows}`; + return `${catalogBanner}${aiBanner}${sandblastBanner}${hint}${rows}`; } function onPrepIncludeCostToggle() { @@ -2274,14 +2306,17 @@ function preFillStep2() { function buildItemFromWizard() { const d = wz.data; - const isAi = wz.itemType === 'ai'; + const isSandblastOnly = !!d.sandblastOnly; + // Sandblast-only AI items lose the AI pricing flag — the AI price included coating costs + // that no longer apply, so the server prices from prep labor instead. + const isAi = wz.itemType === 'ai' && !isSandblastOnly; return { description: d.description || null, quantity: d.quantity || 1, surfaceAreaSqFt: d.surfaceAreaSqFt || 0, estimatedMinutes: d.estimatedMinutes || 0, catalogItemId: d.catalogItemId || null, - manualUnitPrice: d.manualUnitPrice ?? null, + manualUnitPrice: isAi ? (d.manualUnitPrice ?? null) : (d.isGenericItem || d.isSalesItem ? (d.manualUnitPrice ?? null) : null), powderCostOverride: d.powderCostOverride ?? null, isGenericItem: !!d.isGenericItem, isLaborItem: !!d.isLaborItem, @@ -2296,8 +2331,9 @@ function buildItemFromWizard() { prepServices: d.prepServices || [], includePrepCost: d.includePrepCost ?? (wz.itemType !== 'product'), complexity: d.complexity || 'Simple', - aiPhotoTempIds: isAi ? (d.aiPhotoTempIds || []) : [], - aiPhotoFileNames: isAi ? (d.aiPhotoFileNames || []) : [], + // Keep AI photos even for sandblast-only so they get promoted to permanent storage + aiPhotoTempIds: wz.itemType === 'ai' ? (d.aiPhotoTempIds || []) : [], + aiPhotoFileNames: wz.itemType === 'ai' ? (d.aiPhotoFileNames || []) : [], aiTags: isAi ? ((d.aiTags || []).join ? (d.aiTags || []).join(',') : d.aiTags) || null : null, aiPredictionId: isAi ? (d.aiPredictionId ?? null) : null }; @@ -2336,8 +2372,11 @@ function buildCardHtml(item, i) { : { label: 'Custom', cls: 'success', icon: 'bi-rulers' }; const coatCount = item.coats?.length || 0; - const coatBadge = (coatCount > 0) + const isPrepOnly = coatCount === 0 && !item.isGenericItem && !item.isLaborItem && !item.isSalesItem && !item.catalogItemId; + const coatBadge = coatCount > 0 ? `${coatCount} coat${coatCount > 1 ? 's' : ''}` + : isPrepOnly + ? `Prep Only` : ''; const complexityBadge = (!item.isGenericItem && !item.isLaborItem && !item.catalogItemId && item.complexity && item.complexity !== 'Simple')