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')