4d10175ce3
Previously the quick quote omitted the oven charge entirely, so saved quotes were under-priced relative to full quotes from the same items. Pricing: CalculatePricing now calculates ovenBatchCost = (cycleMin/60) × OvenOperatingCostPerHour using DefaultOvenCycleMinutes (fallback 50 min), then adds it to the total as a quote-level charge matching how PricingCalculationService handles oven costs. Save path: SaveQuickQuoteRequest gains OvenBatchCost + OvenCycleMinutes; the Quote record now stores OvenBatchCost, OvenCycleMinutes, and Total = ItemsSubtotal + OvenBatchCost. Display: results card shows a sub-line under the estimate price: "incl. oven 1 batch 50 min: $12.00" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
296 lines
13 KiB
C#
296 lines
13 KiB
C#
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<AiQuickQuoteService> _logger;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<AiQuickQuoteService> logger)
|
|
{
|
|
_config = config;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<AiQuickQuoteResult> 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<Message>
|
|
{
|
|
new Message
|
|
{
|
|
Role = RoleType.User,
|
|
Content = new List<ContentBase>
|
|
{
|
|
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<TextContent>().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<ClaudeQuickQuoteResponse>(json,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to parse Claude quick quote JSON: {Raw}", rawText);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|