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; using PowderCoating.Core.Entities; namespace PowderCoating.Infrastructure.Services; public class AiQuickQuoteService : IAiQuickQuoteService { private readonly IConfiguration _config; private readonly ILogger _logger; /// /// Lean system prompt focused on verbal/phone descriptions. /// Intentionally shorter than the photo-analysis prompt — no image guidance needed. /// The detectedColorNames field lets Claude extract powder names so the server can /// resolve inventory stock without sending the full inventory to the model. /// private const string SystemPrompt = @"You are an expert powder coating estimator. A customer is describing items over the phone or at the shop counter. Based on the verbal description, respond ONLY with a valid JSON object — no markdown, no explanation: { ""description"": ""string - concise item name (e.g., 'Steel bracket set', 'Aluminum wheel rims x4')"", ""surfaceAreaSqFt"": number - estimated surface area per single item in square feet, ""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"", ""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per item (blasting, masking, application, inspection — NOT oven cure time), ""requiresPreheat"": boolean - true for cast iron, cast aluminum, galvanized steel, wrought iron, ""preheatMinutes"": number - 0 if requiresPreheat is false; typical: cast iron 45-60, cast aluminum 30-45, galvanized 30-45, ""confidence"": ""Low"" | ""Medium"" | ""High"", ""reasoning"": ""string - one sentence explaining key assumptions made"", ""detectedColorNames"": [""string""] - color or powder finish names mentioned by the customer verbatim (e.g., [""Matte Black"", ""Alien Silver""]); empty array if none mentioned } Complexity guide: - Simple: flat panels, basic shapes, minimal masking - Moderate: moderate curves, some recesses, standard masking - Complex: intricate geometry, deep recesses, welded assemblies, significant masking - Extreme: highly ornate, deep cavities, heavy prep and masking required When estimating from a verbal description: - Default to steel if material is not stated - Use common reference sizes (car wheel ~1.5-2.5 sqft, motorcycle frame ~8-12 sqft, door ~20 sqft, railing section ~4-6 sqft) - Set confidence to ""Low"" when dimensions are missing or vague - Never ask follow-up questions — return your best estimate with Low confidence instead"; public AiQuickQuoteService(IConfiguration config, ILogger logger) { _config = config; _logger = logger; } /// public async Task AnalyzeAsync( AiQuickQuoteRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb, CompanyAiContext? context = null) { var apiKey = _config["AI:Anthropic:ApiKey"]; if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-")) { return new AiQuickQuoteResult { Success = false, ErrorMessage = "Anthropic API key is not configured. Contact your administrator." }; } try { var client = new AnthropicClient(apiKey); var messageRequest = new MessageParameters { Model = "claude-sonnet-4-6", MaxTokens = 512, Temperature = 0.2m, SystemMessage = BuildSystemPrompt(context), Messages = new List { new Message { Role = RoleType.User, Content = new List { new TextContent { Text = BuildUserPrompt(request) } } } } }; using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var response = await client.Messages.GetClaudeMessageAsync(messageRequest, cts.Token); var rawText = response.FirstMessage?.Text ?? response.Content.OfType().FirstOrDefault()?.Text ?? ""; _logger.LogInformation("Claude quick quote response: {Response}", rawText.Length > 300 ? rawText[..300] : rawText); var claudeResult = ParseResponse(rawText); if (claudeResult == null) { return new AiQuickQuoteResult { Success = false, ErrorMessage = "AI returned an unexpected response format. Please try again." }; } var (unitPrice, total, breakdown) = CalculatePricing(claudeResult, request, costs, avgPowderCostPerLb); return new AiQuickQuoteResult { Success = true, Description = claudeResult.Description, SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt, Complexity = claudeResult.Complexity, EstimatedMinutes = claudeResult.EstimatedMinutes, RequiresPreheat = claudeResult.RequiresPreheat, PreheatMinutes = claudeResult.PreheatMinutes, Confidence = claudeResult.Confidence, Reasoning = claudeResult.Reasoning, // Controller fills in stock status for each entry after an inventory lookup PowderMatches = claudeResult.DetectedColorNames .Select(n => new PowderStockMatch { DetectedColorName = n }) .ToList(), EstimatedUnitPrice = unitPrice, EstimatedTotal = total, Breakdown = breakdown }; } catch (OperationCanceledException) { _logger.LogWarning("Quick quote AI request timed out after 30 s"); return new AiQuickQuoteResult { Success = false, ErrorMessage = "Request timed out. Please try again." }; } catch (Exception ex) { _logger.LogError(ex, "Quick quote AI analysis failed"); return new AiQuickQuoteResult { Success = false, ErrorMessage = "AI analysis failed. Please try again." }; } } private static string BuildSystemPrompt(CompanyAiContext? context) { if (context == null || (string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0)) return SystemPrompt; var sb = new StringBuilder(SystemPrompt); if (!string.IsNullOrWhiteSpace(context.ProfileText)) { sb.AppendLine(); sb.AppendLine(); sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate estimates for this shop:"); sb.AppendLine(context.ProfileText.Trim()); } if (context.AcceptedExamples.Count > 0) { sb.AppendLine(); sb.AppendLine("CALIBRATION EXAMPLES from this shop's accepted quotes:"); foreach (var ex in context.AcceptedExamples) sb.AppendLine($"- {ex.Description}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, ${ex.FinalUnitPrice:F2}/item"); } return sb.ToString(); } private static string BuildUserPrompt(AiQuickQuoteRequest request) { var sb = new StringBuilder(); sb.AppendLine($"Customer description: {request.Description}"); sb.AppendLine($"Quantity: {request.Quantity}"); sb.AppendLine($"Coat count requested: {request.CoatCount}"); return sb.ToString().TrimEnd(); } private ClaudeQuickQuoteResponse? ParseResponse(string rawText) { try { var json = rawText.Trim(); if (json.StartsWith("```")) { var start = json.IndexOf('{'); var end = json.LastIndexOf('}'); if (start >= 0 && end > start) json = json[start..(end + 1)]; } return JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); } catch (Exception ex) { _logger.LogWarning(ex, "Failed to parse Claude quick quote JSON: {Raw}", rawText); return null; } } /// /// Server-side pricing using the same math as AiQuoteService. /// Material-type minimum floors are omitted — verbal descriptions rarely include material, /// and the confidence returned by the AI already reflects that uncertainty. /// private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricing( ClaudeQuickQuoteResponse ai, AiQuickQuoteRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb) { const decimal defaultCoverage = 30m; const decimal defaultEfficiency = 0.65m; var lbsPerCoat = ai.SurfaceAreaSqFt > 0 ? ai.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency) : 0m; var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb; var consumablesSurcharge = materialCost * 0.05m; // System prompt asks Claude for per-item minutes — no division by quantity needed. var perItemMinutes = (decimal)ai.EstimatedMinutes; var laborCost = (perItemMinutes / 60m) * costs.StandardLaborRate; var preheatCost = 0m; var preheatMinutes = 0; if (ai.RequiresPreheat && ai.PreheatMinutes > 0) { preheatMinutes = ai.PreheatMinutes; preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour; } var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m); var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost; var complexityPct = ai.Complexity switch { "Simple" => costs.ComplexitySimplePercent / 100m, "Moderate" => costs.ComplexityModeratePercent / 100m, "Complex" => costs.ComplexityComplexPercent / 100m, "Extreme" => costs.ComplexityExtremePercent / 100m, _ => 0m }; var complexityCharge = subtotalBeforeComplexity * complexityPct; var subtotal = subtotalBeforeComplexity + complexityCharge; if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0) subtotal = costs.ShopMinimumCharge; var unitPrice = Math.Max(0, Math.Round(subtotal, 2)); var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m); // Oven batch charge: 1 batch, DefaultOvenCycleMinutes (fallback 50 min). // Added at quote level (not baked into unitPrice) to match how the regular pricing engine works. var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 50; var ovenBatchCost = Math.Round((ovenCycleMinutes / 60m) * costs.OvenOperatingCostPerHour, 2); var total = unitPrice * request.Quantity + ovenBatchCost; var breakdown = new AiPricingBreakdown { SurfaceAreaSqFt = Math.Round(ai.SurfaceAreaSqFt, 2), PowderLbsPerCoat = Math.Round(lbsPerCoat, 3), CoatCount = request.CoatCount, MaterialCost = Math.Round(materialCost, 2), ConsumablesCost = Math.Round(consumablesSurcharge, 2), EstimatedMinutes = ai.EstimatedMinutes, MaterialMinMinutes = 0, MinFloorApplied = false, LaborCost = Math.Round(laborCost, 2), OvenCycleMinutes = ovenCycleMinutes, OvenCost = ovenBatchCost, RequiresPreheat = ai.RequiresPreheat, PreheatMinutes = preheatMinutes, PreheatCost = Math.Round(preheatCost, 2), SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2), Complexity = ai.Complexity, ComplexityCharge = Math.Round(complexityCharge, 2), SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2), MarkupAmount = Math.Round(markupAmount, 2), UnitPrice = unitPrice }; return (unitPrice, total, breakdown); } }