From 560a2c76b8b0f67b875c3ede4c9caef809efe42a Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 25 Apr 2026 20:35:41 -0400 Subject: [PATCH] Add full category path to AI price check for coating-type context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Skip $0-priced items (placeholders/category headers) in RunAiPriceCheck - Build full category path (e.g. "Cerakote > Firearms") via BuildCategoryPath so Claude receives coating-type context — Cerakote pricing differs significantly from standard powder coat - Update AI system prompt to instruct Claude to use the category path when determining process type, equipment, cure times, and market rates Co-Authored-By: Claude Sonnet 4.6 --- .../Services/AiCatalogPriceCheckService.cs | 2 + .../Controllers/CatalogItemsController.cs | 38 ++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs index 28d7d47..dc13479 100644 --- a/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs +++ b/src/PowderCoating.Infrastructure/Services/AiCatalogPriceCheckService.cs @@ -238,6 +238,8 @@ public class AiCatalogPriceCheckService : IAiCatalogPriceCheckService sb.AppendLine("- \"medium\" — reasonable assumptions were possible"); sb.AppendLine("- \"low\" — item is too vague to estimate reliably (e.g., 'Custom Part', 'Job Special')"); sb.AppendLine(); + sb.AppendLine("The \"category\" field contains the full path, e.g. \"Cerakote > Firearms\" or \"Powder Coat > Wheels\". Use this to determine the coating process — Cerakote items have a very different cost profile than standard powder coat (different equipment, cure times, and market rates). Price accordingly."); + sb.AppendLine(); sb.AppendLine("If the item already has an ApproximateArea or EstimatedMinutes, use those instead of guessing."); sb.AppendLine(); sb.AppendLine("IMPORTANT: Keep responses concise to avoid truncation. Limit assumptions to 20 words max. Limit reasoning to 25 words max."); diff --git a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs index 5eb3ee6..f9c7b22 100644 --- a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs @@ -938,8 +938,8 @@ namespace PowderCoating.Web.Controllers r => r.CompanyId == currentUser.CompanyId); var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault(); - var activeItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive); - ViewBag.ActiveItemCount = activeItems.Count(); + var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0); + ViewBag.ActiveItemCount = pricedItems.Count(); CatalogPriceCheckReportDto? dto = null; if (report != null) @@ -990,16 +990,23 @@ namespace PowderCoating.Web.Controllers try { - // Load all active catalog items with categories + // Load active catalog items with a real price — skip $0 items (placeholders, + // category headers, etc.) since there's no pricing to evaluate. var items = (await _unitOfWork.CatalogItems.FindAsync( - ci => ci.IsActive, false, ci => ci.Category)).ToList(); + ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList(); if (items.Count == 0) { - TempData["Warning"] = "No active catalog items to analyze."; + TempData["Warning"] = "No priced catalog items to analyze. Add prices to your catalog items first."; return RedirectToAction(nameof(AiPriceCheck)); } + // Load all categories so we can build full paths (e.g. "Cerakote > Firearms"). + // The full path gives Claude the coating-type context it needs — an item in + // "Firearms" under "Cerakote" costs very differently than one under "Powder Coat". + var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync()) + .ToDictionary(c => c.Id); + // Load company operating costs var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync( c => c.CompanyId == currentUser.CompanyId)).FirstOrDefault(); @@ -1012,7 +1019,7 @@ namespace PowderCoating.Web.Controllers Id = i.Id, Name = i.Name, Description = i.Description, - CategoryName = i.Category?.Name ?? "Uncategorized", + CategoryName = BuildCategoryPath(i.CategoryId, allCategories), CurrentPrice = i.DefaultPrice, ApproximateAreaSqFt = i.ApproximateArea, EstimatedMinutes = i.DefaultEstimatedMinutes, @@ -1083,6 +1090,25 @@ namespace PowderCoating.Web.Controllers $"Blaster ${c.SandblasterCostPerHour:F2}/hr | Booth ${c.CoatingBoothCostPerHour:F2}/hr | " + $"Powder ${c.PowderCostPerSqFt:F2}/sqft | " + $"{(c.PricingMode == "margin" ? "Margin" : "Markup")} {c.MarkupOrMarginPercent:F1}%"; + + /// + /// Walks up the category parent chain to produce a full path like "Cerakote > Firearms", + /// giving Claude the coating-type context it needs for accurate pricing analysis. + /// + private static string BuildCategoryPath(int? categoryId, Dictionary all) + { + if (categoryId == null) return "Uncategorized"; + var parts = new List(); + var current = all.GetValueOrDefault(categoryId.Value); + while (current != null) + { + parts.Insert(0, current.Name); + current = current.ParentCategoryId.HasValue + ? all.GetValueOrDefault(current.ParentCategoryId.Value) + : null; + } + return parts.Count > 0 ? string.Join(" > ", parts) : "Uncategorized"; + } } // Helper class for hierarchical display