31d305b66a
- add grouped platform admin hub pages, view models, and shared card UI\n- simplify the super admin nav and dashboard quick links around the new hubs\n- fix the AiQuoteService EstimatedMinutes assignment so the infrastructure project builds cleanly
642 lines
33 KiB
C#
642 lines
33 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.Application.Services;
|
|
using PowderCoating.Core.Entities;
|
|
|
|
namespace PowderCoating.Infrastructure.Services;
|
|
|
|
public class AiQuoteService : IAiQuoteService
|
|
{
|
|
private readonly IConfiguration _config;
|
|
private readonly ILogger<AiQuoteService> _logger;
|
|
|
|
/// <summary>
|
|
/// The core system prompt that defines Claude's role as a powder coating estimator.
|
|
/// It specifies the exact JSON schema the model must return, complexity definitions,
|
|
/// surface-area estimation rules, and material-specific active-labor guidance for cast
|
|
/// iron, cast aluminum, galvanized steel, and heavy steel. The prompt deliberately
|
|
/// excludes oven dwell time from <c>estimatedMinutes</c> because cure time is added at
|
|
/// the quote level to avoid double-charging when multiple items share the same oven cycle.
|
|
/// Material-specific preheat/outgassing handling is separated into <c>requiresPreheat</c>
|
|
/// and <c>preheatMinutes</c> fields so the pricing engine can charge a distinct extra oven
|
|
/// cost only for materials that truly need it. The tags list is a closed enumeration to
|
|
/// prevent free-text values that would break tag-based filtering in future reporting.
|
|
/// </summary>
|
|
private const string BaseSystemPrompt = @"You are an expert powder coating estimator. Your job is to analyze photos of items to be powder coated and provide accurate estimates.
|
|
|
|
When analyzing images, you must respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation, just the JSON:
|
|
|
|
{
|
|
""description"": ""string - concise item name (e.g., 'Steel bracket', 'Aluminum wheel rim', '5-piece railing section')"",
|
|
""surfaceAreaSqFt"": number - estimated total surface area in square feet per single item (use the reference dimension provided),
|
|
""complexity"": ""Simple"" | ""Moderate"" | ""Complex"" | ""Extreme"",
|
|
""estimatedMinutes"": number - estimated ACTIVE LABOR time in minutes per single item covering: sandblasting/media blasting, chemical stripping (if needed), masking, racking/hanging, powder application, unracking, and inspection. DO NOT include oven cure/dwell time — that is priced separately. Only count minutes where a worker is actively doing something.
|
|
""requiresPreheat"": boolean - true if this material needs a dedicated outgassing or preheat oven cycle BEFORE coating (cast iron, cast aluminum, galvanized steel, wrought iron). False for standard steel, aluminum sheet/extrusion, stainless.
|
|
""preheatMinutes"": number - duration of the preheat/outgassing oven cycle in minutes. Set to 0 if requiresPreheat is false. Typical values: cast iron 45-60, cast aluminum 30-45, galvanized 30-45.
|
|
""confidence"": ""Low"" | ""Medium"" | ""High"",
|
|
""needsFollowUp"": boolean,
|
|
""followUpQuestion"": ""string or null"",
|
|
""reasoning"": ""string - brief explanation of your estimate"",
|
|
""tags"": [""string""] - 1 to 3 tags from ONLY this fixed list: automotive, railing, furniture, structural, flat-panel, tubular, ornamental, architectural, industrial, agricultural, equipment, enclosure, signage, bracket, gate, wheel, frame, sheet-metal
|
|
}
|
|
|
|
Complexity guide (affects both prep difficulty and coating time):
|
|
- Simple: flat panels, basic shapes, easy access to all surfaces, minimal masking needed
|
|
- Moderate: moderate curves, some recessed areas, standard brackets, light masking
|
|
- Complex: intricate geometry, deep recesses, welded assemblies, many surfaces, significant masking or stripping needed
|
|
- Extreme: highly ornate, very deep cavities, extreme surface variation, heavy prep and masking required
|
|
|
|
Surface area estimation:
|
|
- Use the reference dimension provided to calibrate your estimate
|
|
- Account for ALL surfaces that need coating (front, back, sides, edges)
|
|
- For hollow items (tubes, pipes), include interior only if specified
|
|
- Express as sq ft per single item
|
|
|
|
MATERIAL-SPECIFIC CONSIDERATIONS — these directly affect estimatedMinutes and complexity:
|
|
|
|
Cast Iron (active labor only — exclude oven dwell):
|
|
- Outgassing preheat setup: worker loads piece into oven, waits for outgassing, unloads — count ~10 min active handling, NOT the full oven cycle
|
|
- Porous surface needs aggressive sandblasting/media blasting — add 15-30 min depending on size
|
|
- Heavy pieces (30+ lbs) require two-person handling for racking/unracking — add 10-15 min vs lighter items
|
|
- Minimum complexity for cast iron is Moderate; anything with relief detail, fins, or recesses is Complex or Extreme
|
|
- Typical active labor for cast iron: 35-70 min depending on size and detail
|
|
|
|
Heavy Steel (30+ lbs, active labor only):
|
|
- Two-person handling for racking/unracking — add 10-15 min vs lighter items
|
|
- Heavier blasting/prep required for thick sections
|
|
|
|
Cast Aluminum (active labor only):
|
|
- Outgassing setup similar to cast iron — add 10 min active handling
|
|
- Chemical conversion coat or etch primer often needed — add 15-20 min if applicable
|
|
|
|
Galvanized Steel (active labor only):
|
|
- Must be chemically stripped or heavily blasted to ensure adhesion — add 20-30 min prep
|
|
|
|
If the user mentions the item is heavy, large, cast iron, or galvanized, ensure estimatedMinutes reflects the extra active prep and handling time for that material.
|
|
|
|
If you need clarification to give a good estimate, set needsFollowUp=true and provide one specific, answerable question in followUpQuestion.
|
|
Only ask follow-up questions if truly needed — prefer to make reasonable assumptions and note them in reasoning.";
|
|
|
|
/// <summary>
|
|
/// Builds the per-request system prompt by appending company-specific context to
|
|
/// <see cref="BaseSystemPrompt"/>. If no context is provided, or if both the profile
|
|
/// text and accepted-example list are empty, the base prompt is returned as-is to avoid
|
|
/// unnecessary string allocation. When context is present, the company's
|
|
/// <c>AiContextProfile</c> free-text block (from the AI Profile tab in Company Settings)
|
|
/// is appended first, followed by a few-shot calibration section listing previously
|
|
/// accepted quote items with their actual surface area, complexity, minutes, and unit
|
|
/// price. This two-layer approach lets shop owners nudge the AI toward their shop's
|
|
/// real-world pricing without modifying the core prompt — the profile text captures
|
|
/// qualitative rules ("we always charge a minimum of 20 min for racking") while the
|
|
/// few-shot examples capture quantitative calibration from accepted quotes.
|
|
/// </summary>
|
|
private static string BuildSystemPrompt(CompanyAiContext? context)
|
|
{
|
|
if (context == null ||
|
|
(string.IsNullOrWhiteSpace(context.ProfileText) && context.AcceptedExamples.Count == 0))
|
|
return BaseSystemPrompt;
|
|
|
|
var sb = new StringBuilder(BaseSystemPrompt);
|
|
|
|
if (!string.IsNullOrWhiteSpace(context.ProfileText))
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine();
|
|
sb.AppendLine("COMPANY-SPECIFIC CONTEXT — use this to calibrate your estimates for this particular shop:");
|
|
sb.AppendLine(context.ProfileText.Trim());
|
|
}
|
|
|
|
if (context.AcceptedExamples.Count > 0)
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine();
|
|
sb.AppendLine("CALIBRATION EXAMPLES — items this company has previously priced and accepted:");
|
|
sb.AppendLine("Use these as reference points to calibrate your surface area, complexity, time, and pricing estimates.");
|
|
foreach (var ex in context.AcceptedExamples)
|
|
{
|
|
var tags = string.IsNullOrWhiteSpace(ex.Tags) ? "" : $" [{ex.Tags}]";
|
|
sb.AppendLine($"- {ex.Description}{tags}: {ex.SurfaceAreaSqFt:F1} sqft, {ex.Complexity}, {ex.EstimatedMinutes} min, priced at ${ex.FinalUnitPrice:F2}/unit");
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of <see cref="AiQuoteService"/>. The Anthropic API key is
|
|
/// read per call (not cached at construction time) so that configuration updates take
|
|
/// effect without restarting the application.
|
|
/// </summary>
|
|
public AiQuoteService(IConfiguration config, ILogger<AiQuoteService> logger)
|
|
{
|
|
_config = config;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Analyzes one or more photos of an item to be powder coated and returns an estimated
|
|
/// surface area, complexity, active labor minutes, pricing breakdown, and optional
|
|
/// follow-up question. Supports up to two conversation rounds: on the first call the
|
|
/// photos are embedded as base64 image content blocks alongside the user prompt; on
|
|
/// subsequent calls the prior assistant and user turns are replayed from
|
|
/// <see cref="AiAnalyzeItemRequest.ConversationHistory"/> so Claude retains context
|
|
/// without the photos being re-uploaded. The round cap of 2 is enforced client-side
|
|
/// by forcing <c>NeedsFollowUp = false</c> on round 2 regardless of what Claude returns,
|
|
/// preventing an infinite loop if the model keeps asking questions. Pricing is calculated
|
|
/// locally in <see cref="CalculatePricingPreview"/> using the company's
|
|
/// <see cref="CompanyOperatingCosts"/> rather than asking Claude to price the item,
|
|
/// ensuring the estimate is always consistent with the company's configured rates.
|
|
/// </summary>
|
|
public async Task<AiAnalyzeItemResult> AnalyzeItemAsync(
|
|
AiAnalyzeItemRequest request,
|
|
List<(byte[] Data, string ContentType, string FileName)> photos,
|
|
CompanyOperatingCosts costs,
|
|
decimal avgPowderCostPerLb,
|
|
CompanyAiContext? context = null,
|
|
CompanyBlastSetup? selectedBlastSetup = null)
|
|
{
|
|
var apiKey = _config["AI:Anthropic:ApiKey"];
|
|
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
|
{
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
var client = new AnthropicClient(apiKey);
|
|
|
|
// Build the conversation messages
|
|
var messages = new List<Message>();
|
|
|
|
// Add prior conversation history for follow-up rounds
|
|
if (request.ConversationHistory?.Count > 0)
|
|
{
|
|
foreach (var turn in request.ConversationHistory)
|
|
{
|
|
messages.Add(new Message
|
|
{
|
|
Role = turn.Role == "assistant" ? RoleType.Assistant : RoleType.User,
|
|
Content = new List<ContentBase> { new TextContent { Text = turn.Content } }
|
|
});
|
|
}
|
|
// Add follow-up answer as latest user message
|
|
if (!string.IsNullOrWhiteSpace(request.FollowUpAnswer))
|
|
{
|
|
messages.Add(new Message
|
|
{
|
|
Role = RoleType.User,
|
|
Content = new List<ContentBase>
|
|
{
|
|
new TextContent
|
|
{
|
|
Text = $"Answer to your question: {request.FollowUpAnswer}\n\nPlease now provide your final estimate as JSON."
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// First call — build the initial user message with photos + context
|
|
var contentParts = new List<ContentBase>();
|
|
|
|
// Attach each photo as base64 image
|
|
foreach (var (data, contentType, fileName) in photos)
|
|
{
|
|
var mediaType = contentType.ToLowerInvariant() switch
|
|
{
|
|
"image/jpeg" => "image/jpeg",
|
|
"image/jpg" => "image/jpeg",
|
|
"image/png" => "image/png",
|
|
"image/gif" => "image/gif",
|
|
"image/webp" => "image/webp",
|
|
_ => "image/jpeg"
|
|
};
|
|
|
|
contentParts.Add(new ImageContent
|
|
{
|
|
Source = new ImageSource
|
|
{
|
|
MediaType = mediaType,
|
|
Data = Convert.ToBase64String(data)
|
|
}
|
|
});
|
|
}
|
|
|
|
var userText = BuildUserPrompt(request, costs, avgPowderCostPerLb, selectedBlastSetup);
|
|
contentParts.Add(new TextContent { Text = userText });
|
|
|
|
messages.Add(new Message
|
|
{
|
|
Role = RoleType.User,
|
|
Content = contentParts
|
|
});
|
|
}
|
|
|
|
var messageRequest = new MessageParameters
|
|
{
|
|
Model = "claude-sonnet-4-6",
|
|
MaxTokens = 1024,
|
|
// Low temperature for deterministic estimation — the prompt already constrains
|
|
// the model with exact blast/coating rates, so creativity adds noise, not value.
|
|
Temperature = 0.2m,
|
|
SystemMessage = BuildSystemPrompt(context),
|
|
Messages = messages
|
|
};
|
|
|
|
// 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<TextContent>().FirstOrDefault()?.Text
|
|
?? "";
|
|
|
|
_logger.LogInformation("Claude AI response for quote analysis: {Response}", rawText.Length > 500 ? rawText[..500] : rawText);
|
|
|
|
// Parse JSON response
|
|
var claudeResult = ParseClaudeResponse(rawText);
|
|
if (claudeResult == null)
|
|
{
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = "AI returned an unexpected response format. Please try again."
|
|
};
|
|
}
|
|
|
|
// Determine follow-up round
|
|
var currentRound = request.ConversationHistory?.Count > 0 ? 2 : 1;
|
|
|
|
// Build updated conversation history
|
|
var newHistory = new List<AiConversationTurn>(request.ConversationHistory ?? new List<AiConversationTurn>());
|
|
if (request.FollowUpAnswer != null)
|
|
newHistory.Add(new AiConversationTurn { Role = "user", Content = $"Answer: {request.FollowUpAnswer}" });
|
|
newHistory.Add(new AiConversationTurn { Role = "assistant", Content = rawText });
|
|
|
|
// If AI wants a follow-up but we've already done 2 rounds, force completion
|
|
var needsFollowUp = claudeResult.NeedsFollowUp && currentRound < 2;
|
|
|
|
if (needsFollowUp)
|
|
{
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = true,
|
|
NeedsFollowUp = true,
|
|
FollowUpQuestion = claudeResult.FollowUpQuestion,
|
|
FollowUpRound = currentRound,
|
|
ConversationHistory = newHistory
|
|
};
|
|
}
|
|
|
|
// Calculate pricing preview using operating costs
|
|
var (unitPrice, total, breakdown) = CalculatePricingPreview(claudeResult, request, costs, avgPowderCostPerLb);
|
|
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = true,
|
|
NeedsFollowUp = false,
|
|
Description = claudeResult.Description,
|
|
SurfaceAreaSqFt = claudeResult.SurfaceAreaSqFt,
|
|
Complexity = claudeResult.Complexity,
|
|
EstimatedMinutes = claudeResult.EstimatedMinutes,
|
|
AiReasoning = claudeResult.Reasoning,
|
|
Confidence = claudeResult.Confidence,
|
|
EstimatedUnitPrice = unitPrice,
|
|
EstimatedTotal = total,
|
|
PowderCostPerLb = avgPowderCostPerLb,
|
|
CoverageSqFtPerLb = 30m,
|
|
TransferEfficiency = 65m,
|
|
ConversationHistory = newHistory,
|
|
Tags = claudeResult.Tags,
|
|
Breakdown = breakdown
|
|
};
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogWarning("Claude AI request timed out after 60 seconds (quote analysis)");
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = false,
|
|
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");
|
|
return new AiAnalyzeItemResult
|
|
{
|
|
Success = false,
|
|
ErrorMessage = "The AI service encountered an error. Please try again."
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the initial user message that accompanies the photo content blocks on the first
|
|
/// analysis round. It injects the reference dimension, material type, estimated weight,
|
|
/// quantity, desired finish, and coat count so Claude can calibrate surface area and active
|
|
/// labor time without asking follow-up questions for common inputs. The company's labor
|
|
/// rate and complexity multipliers are included so Claude can ground its reasoning in real
|
|
/// shop costs, even though final pricing is computed locally by
|
|
/// <see cref="CalculatePricingPreview"/>. The weight line is conditionally included only
|
|
/// when a weight is provided, because mentioning "not provided" for weight prompts Claude
|
|
/// to ask about it, which wastes a follow-up round for items where weight is irrelevant.
|
|
/// </summary>
|
|
private static string BuildUserPrompt(AiAnalyzeItemRequest request, CompanyOperatingCosts costs, decimal avgPowderCostPerLb, CompanyBlastSetup? selectedBlastSetup = null)
|
|
{
|
|
var materialLine = string.IsNullOrWhiteSpace(request.MaterialType)
|
|
? "- Material type: Unknown (infer from photo if possible)"
|
|
: $"- Material type: {request.MaterialType}";
|
|
|
|
var weightLine = request.EstimatedWeightLbs.HasValue && request.EstimatedWeightLbs > 0
|
|
? $"- Estimated weight: {request.EstimatedWeightLbs:F0} lbs per piece — factor in heavy-handling time and extended heat-soak/cure accordingly"
|
|
: "- Estimated weight: Not provided";
|
|
|
|
// Use the explicitly selected blast setup when provided; fall back to company-level costs.
|
|
decimal blastRate;
|
|
string blastSetupLabel;
|
|
if (selectedBlastSetup != null)
|
|
{
|
|
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(selectedBlastSetup);
|
|
blastSetupLabel = $" ({selectedBlastSetup.Name})";
|
|
}
|
|
else
|
|
{
|
|
blastRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(costs);
|
|
blastSetupLabel = string.Empty;
|
|
}
|
|
|
|
var coatingRate = ShopCapabilityCalculator.GetCoatingRateSqFtPerHour(costs);
|
|
|
|
// Build a shop-speed context line only when the shop has calibrated their equipment.
|
|
// An uncalibrated shop (CFM = 0, no override) gets a generic instruction so the AI
|
|
// falls back to industry-average times rather than anchoring on a misleading zero.
|
|
string shopSpeedLine;
|
|
if (blastRate > 0)
|
|
{
|
|
shopSpeedLine = $"- THIS SHOP'S blast rate{blastSetupLabel}: ~{blastRate:F0} sqft/hr — use this to derive sandblasting time (surface area ÷ blast rate), NOT generic industry averages";
|
|
}
|
|
else
|
|
{
|
|
shopSpeedLine = "- Shop blast rate: not calibrated — use conservative industry-average times for this shop tier";
|
|
}
|
|
|
|
string coatingSpeedLine;
|
|
if (coatingRate > 0)
|
|
coatingSpeedLine = $"- THIS SHOP'S coating application rate: ~{coatingRate:F0} sqft/hr — use this to derive coating time (surface area ÷ coating rate), NOT generic industry averages";
|
|
else
|
|
coatingSpeedLine = "- Shop coating rate: not calibrated — use conservative industry-average coating times for this shop tier";
|
|
|
|
var rateInstruction = (blastRate > 0 || coatingRate > 0)
|
|
? "IMPORTANT: For estimatedMinutes, you MUST use this shop's specific rates above where provided, not generic industry speeds."
|
|
: "IMPORTANT: For estimatedMinutes, use conservative industry-average times appropriate for a professional powder coating shop.";
|
|
|
|
return $@"Please analyze the item(s) in the photo(s) for powder coating estimation.
|
|
|
|
User-provided context:
|
|
- Reference dimension: {request.ReferenceDimension}
|
|
{materialLine}
|
|
{weightLine}
|
|
- Quantity to be coated: {request.Quantity} piece(s)
|
|
- Desired color/finish: {request.DesiredColor}
|
|
- Number of coating stages: {request.CoatCount}
|
|
|
|
Company operating costs for your reference:
|
|
- Labor rate: ${costs.StandardLaborRate}/hr
|
|
- Complexity multipliers — Simple: +{costs.ComplexitySimplePercent}%, Moderate: +{costs.ComplexityModeratePercent}%, Complex: +{costs.ComplexityComplexPercent}%, Extreme: +{costs.ComplexityExtremePercent}%
|
|
{shopSpeedLine}
|
|
{coatingSpeedLine}
|
|
|
|
{rateInstruction}
|
|
Sandblasting time = surface area of item ÷ shop blast rate (sqft/hr), adjusted for part complexity (harder-to-reach areas take more passes).
|
|
Coating time = surface area ÷ shop coating rate, adjusted for masking and complexity.
|
|
Include racking/unracking, inspection, and any material-specific prep (preheat handling, chemical stripping) as ACTIVE labor time.
|
|
Do NOT include oven cure/dwell — that is priced separately.
|
|
Estimate the surface area for ONE item. I will multiply by quantity later.
|
|
Respond with the JSON object only.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses Claude's raw text response into a <see cref="ClaudeQuoteResponse"/> by stripping
|
|
/// any surrounding markdown code fences and deserializing the JSON payload. Returns null
|
|
/// (rather than throwing) when parsing fails so that <see cref="AnalyzeItemAsync"/> can
|
|
/// return a structured error result instead of an unhandled exception. The fence-stripping
|
|
/// logic searches for the first <c>{</c> and last <c>}</c> to handle cases where Claude
|
|
/// includes a language tag on the fence (e.g., <c>```json</c>).
|
|
/// </summary>
|
|
private static ClaudeQuoteResponse? ParseClaudeResponse(string rawText)
|
|
{
|
|
try
|
|
{
|
|
// Strip any markdown code fences
|
|
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)];
|
|
}
|
|
|
|
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
|
return JsonSerializer.Deserialize<ClaudeQuoteResponse>(json, options);
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Translates Claude's estimated surface area, complexity, and active-labor minutes into
|
|
/// a concrete unit price using the company's configured operating costs. Key design decisions:
|
|
///
|
|
/// <list type="bullet">
|
|
/// <item>Powder consumption uses fixed defaults (30 sq ft/lb coverage, 65% transfer
|
|
/// efficiency) because most shops have not entered per-powder efficiency values, and the
|
|
/// defaults represent industry averages for electrostatic spray application.</item>
|
|
/// <item>Material minimum-minute floors are applied after dividing Claude's total batch
|
|
/// minutes by quantity. This prevents the AI from returning, for example, 20 total minutes
|
|
/// for a batch of 5 cast-iron pieces when each piece realistically requires at least 90
|
|
/// active minutes of preheat setup, blasting, and handling.</item>
|
|
/// <item>The standard oven cure cycle is not embedded in the unit price because it is
|
|
/// priced at the quote level to avoid double-charging when multiple items share the same
|
|
/// oven batch. <c>OvenCost</c> is therefore always 0 in the breakdown returned here.</item>
|
|
/// <item>Preheat/outgassing cost is the exception: it is an EXTRA oven cycle unique to
|
|
/// this item (cast iron, cast aluminum, galvanized), so it IS included in the unit price.</item>
|
|
/// <item>General markup applies only to material costs (powder + consumables), not to
|
|
/// labor or oven rates, because labor and oven rates are already set as billable rates.</item>
|
|
/// <item>Complexity multiplier is applied to the entire pre-complexity subtotal
|
|
/// (materials + labor + preheat) rather than just materials, reflecting the real-world
|
|
/// cost of complex work: more masking material, higher risk of rework, slower throughput.</item>
|
|
/// <item>Only computed dollar amounts are included in the breakdown — rates and percentages
|
|
/// are omitted to avoid exposing proprietary pricing configuration to the client.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
private static (decimal UnitPrice, decimal Total, AiPricingBreakdown Breakdown) CalculatePricingPreview(
|
|
ClaudeQuoteResponse aiResult,
|
|
AiAnalyzeItemRequest request,
|
|
CompanyOperatingCosts costs,
|
|
decimal avgPowderCostPerLb)
|
|
{
|
|
// Material cost: powder per coat
|
|
const decimal defaultCoverage = 30m; // sq ft/lb
|
|
const decimal defaultEfficiency = 0.65m; // 65%
|
|
var lbsPerCoat = aiResult.SurfaceAreaSqFt > 0
|
|
? aiResult.SurfaceAreaSqFt / (defaultCoverage * defaultEfficiency)
|
|
: 0m;
|
|
var materialCost = lbsPerCoat * request.CoatCount * avgPowderCostPerLb;
|
|
var consumablesSurcharge = materialCost * 0.05m;
|
|
|
|
// Material-type minimum time floors — safety net for AI underestimates.
|
|
// These reflect the bare minimum realistic time for each material regardless of what the AI returns.
|
|
// Active-labor-only floors (oven dwell excluded from estimatedMinutes per prompt instructions).
|
|
var materialMinMinutes = (request.MaterialType ?? "").ToLowerInvariant() switch
|
|
{
|
|
var m when m.Contains("cast iron") => 90,
|
|
var m when m.Contains("cast aluminum") => 70,
|
|
var m when m.Contains("galvanized") => 50,
|
|
var m when m.Contains("heavy steel") => 40,
|
|
var m when m.Contains("wrought iron") => 75,
|
|
_ => 0
|
|
};
|
|
|
|
// Labor cost — AI returns per-item minutes (both system prompt and user prompt say "per single item").
|
|
// Unit price is per item; the caller multiplies by quantity for the line total.
|
|
var rawPerItemMinutes = aiResult.EstimatedMinutes;
|
|
var minFloorApplied = materialMinMinutes > 0 && rawPerItemMinutes < materialMinMinutes;
|
|
var perItemMinutes = minFloorApplied ? materialMinMinutes : rawPerItemMinutes;
|
|
var laborHours = perItemMinutes / 60m;
|
|
var laborCost = laborHours * costs.StandardLaborRate;
|
|
|
|
// Standard cure cycle is handled at the quote level — not embedded here to avoid double-charging.
|
|
var ovenCycleMinutes = costs.DefaultOvenCycleMinutes > 0 ? costs.DefaultOvenCycleMinutes : 45;
|
|
|
|
// Material-specific preheat/outgassing cycle — this is an EXTRA oven cycle beyond the standard cure,
|
|
// charged at the oven operating rate (not labor rate) directly to this item.
|
|
var preheatCost = 0m;
|
|
var preheatMinutes = 0;
|
|
if (aiResult.RequiresPreheat && aiResult.PreheatMinutes > 0)
|
|
{
|
|
preheatMinutes = aiResult.PreheatMinutes;
|
|
preheatCost = (preheatMinutes / 60m) * costs.OvenOperatingCostPerHour;
|
|
}
|
|
|
|
// Markup applies to materials only — labor and oven rates are already set as billable rates.
|
|
var materialWithMarkup = (materialCost + consumablesSurcharge) * (1 + costs.GeneralMarkupPercentage / 100m);
|
|
|
|
var subtotalBeforeComplexity = materialWithMarkup + laborCost + preheatCost;
|
|
|
|
// Complexity multiplier
|
|
var complexityPct = aiResult.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;
|
|
|
|
// Additional coat charge — each coat beyond the first adds AdditionalCoatLaborPercent % of
|
|
// the subtotal, matching the formula in PricingCalculationService.CalculateQuoteItemPriceAsync.
|
|
// The coat count here comes from the wizard's step-2 field (aiCoatCount), so the preview
|
|
// reflects whatever multi-coat configuration the user specified before clicking Analyze.
|
|
if (request.CoatCount > 1)
|
|
{
|
|
var additionalCoatCharge = subtotal * (request.CoatCount - 1) * (costs.AdditionalCoatLaborPercent / 100m);
|
|
subtotal += additionalCoatCharge;
|
|
}
|
|
|
|
var markupAmount = (materialCost + consumablesSurcharge) * (costs.GeneralMarkupPercentage / 100m);
|
|
|
|
// Apply shop minimum
|
|
if (subtotal < costs.ShopMinimumCharge && costs.ShopMinimumCharge > 0)
|
|
subtotal = costs.ShopMinimumCharge;
|
|
|
|
var unitPrice = Math.Max(0, Math.Round(subtotal, 2));
|
|
var total = unitPrice * request.Quantity;
|
|
|
|
var breakdown = new AiPricingBreakdown
|
|
{
|
|
SurfaceAreaSqFt = Math.Round(aiResult.SurfaceAreaSqFt, 2),
|
|
PowderLbsPerCoat = Math.Round(lbsPerCoat, 3),
|
|
CoatCount = request.CoatCount,
|
|
MaterialCost = Math.Round(materialCost, 2),
|
|
ConsumablesCost = Math.Round(consumablesSurcharge, 2),
|
|
EstimatedMinutes = perItemMinutes,
|
|
MaterialMinMinutes = materialMinMinutes,
|
|
MinFloorApplied = minFloorApplied,
|
|
LaborCost = Math.Round(laborCost, 2),
|
|
OvenCycleMinutes = ovenCycleMinutes,
|
|
OvenCost = 0m,
|
|
RequiresPreheat = aiResult.RequiresPreheat,
|
|
PreheatMinutes = preheatMinutes,
|
|
PreheatCost = Math.Round(preheatCost, 2),
|
|
SubtotalBeforeComplexity = Math.Round(subtotalBeforeComplexity, 2),
|
|
Complexity = aiResult.Complexity,
|
|
ComplexityCharge = Math.Round(complexityCharge, 2),
|
|
SubtotalBeforeMarkup = Math.Round(subtotalBeforeComplexity, 2),
|
|
MarkupAmount = Math.Round(markupAmount, 2),
|
|
UnitPrice = unitPrice,
|
|
};
|
|
|
|
return (unitPrice, total, breakdown);
|
|
}
|
|
}
|