Lazily enrich catalog specs from TDS on first use
Specific gravity, coverage, and ~55% of cure specs aren't in the Columbia feed. Rather than read 2,400 TDS PDFs up front, enrich a catalog item the first time it's actually used: - FetchTdsCureSpecsAsync now also extracts specific gravity from the TDS. - New EnsureCatalogTdsSpecsAsync fills a catalog item's specific gravity (and any missing cure temp/time) from its TDS, then derives theoretical coverage (192.3 / (SG x mils)). No-op once specific gravity is known or when there's no TDS; persists to the catalog so the work is done once and benefits everyone. - Hooked into the catalog->inventory paths (CreateIncomingFromCatalog, the custom-powder receive enrichment, and ReceivePowderFromCatalog) so a powder's full specs land on both the catalog and the new inventory record. DashboardController gains the AI lookup service for this. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
|
|||||||
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
|
/// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific
|
||||||
/// Called when the main lookup found a TDS URL but cure specs are still missing.
|
/// gravity. Called when the main lookup found a TDS URL but specs are still missing.
|
||||||
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
|
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
|
||||||
|
/// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
|
||||||
|
/// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
|
||||||
|
/// it's done once and benefits every future use. Returns true if anything was filled.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Anthropic.SDK.Messaging;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Services;
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
@@ -541,18 +542,20 @@ Rules:
|
|||||||
|
|
||||||
// Targeted prompt: we only need cure specs from this document
|
// Targeted prompt: we only need cure specs from this document
|
||||||
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
||||||
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
|
Extract the cure schedule and the specific gravity. Respond with a valid JSON object — no markdown, no explanation:
|
||||||
|
|
||||||
{
|
{
|
||||||
""cureTemperatureF"": number or null,
|
""cureTemperatureF"": number or null,
|
||||||
""cureTimeMinutes"": number or null,
|
""cureTimeMinutes"": number or null,
|
||||||
""reasoning"": ""one sentence: what cure schedule you found""
|
""specificGravity"": number or null,
|
||||||
|
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
||||||
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
||||||
- If neither value can be found in the document, return null for both.";
|
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.2–1.8. Null if not stated.
|
||||||
|
- Return null for any value not found in the document.";
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("Technical Data Sheet content:");
|
sb.AppendLine("Technical Data Sheet content:");
|
||||||
@@ -603,11 +606,12 @@ Rules:
|
|||||||
Success = true,
|
Success = true,
|
||||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||||
|
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||||
Reasoning = GetString(parsed, "reasoning"),
|
Reasoning = GetString(parsed, "reasoning"),
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
|
_logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})",
|
||||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
|
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -617,6 +621,58 @@ Rules:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
|
||||||
|
{
|
||||||
|
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
|
||||||
|
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
|
||||||
|
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
|
||||||
|
if (!tds.Success)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (tds.SpecificGravity is > 0)
|
||||||
|
{
|
||||||
|
catalog.SpecificGravity = tds.SpecificGravity;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
|
||||||
|
{
|
||||||
|
catalog.CureTemperatureF = tds.CureTemperatureF;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
|
||||||
|
{
|
||||||
|
catalog.CureTimeMinutes = tds.CureTimeMinutes;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Derive theoretical coverage once specific gravity is known.
|
||||||
|
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
|
||||||
|
{
|
||||||
|
catalog.CoverageSqFtPerLb = Math.Round(
|
||||||
|
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
|
||||||
|
2, MidpointRounding.AwayFromZero);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
catalog.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
|
||||||
|
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
|
||||||
|
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class DashboardController : Controller
|
|||||||
private readonly ICompanyConfigHealthService _configHealth;
|
private readonly ICompanyConfigHealthService _configHealth;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ISubscriptionService _subscriptionService;
|
private readonly ISubscriptionService _subscriptionService;
|
||||||
|
private readonly IInventoryAiLookupService _aiLookupService;
|
||||||
|
|
||||||
public DashboardController(
|
public DashboardController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -33,7 +34,8 @@ public class DashboardController : Controller
|
|||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
ICompanyConfigHealthService configHealth,
|
ICompanyConfigHealthService configHealth,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ISubscriptionService subscriptionService)
|
ISubscriptionService subscriptionService,
|
||||||
|
IInventoryAiLookupService aiLookupService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -42,6 +44,7 @@ public class DashboardController : Controller
|
|||||||
_configHealth = configHealth;
|
_configHealth = configHealth;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_subscriptionService = subscriptionService;
|
_subscriptionService = subscriptionService;
|
||||||
|
_aiLookupService = aiLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -868,8 +871,12 @@ public class DashboardController : Controller
|
|||||||
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
|
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
|
||||||
{
|
{
|
||||||
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
|
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
|
||||||
if (catalog != null)
|
if (catalog == null)
|
||||||
ApplyCatalogToInventory(item, catalog);
|
return;
|
||||||
|
|
||||||
|
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||||
|
ApplyCatalogToInventory(item, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -987,6 +994,9 @@ public class DashboardController : Controller
|
|||||||
if (catalog == null)
|
if (catalog == null)
|
||||||
return Json(new { success = false, needsDetails = true });
|
return Json(new { success = false, needsDetails = true });
|
||||||
|
|
||||||
|
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||||
|
|
||||||
// Resolve the company's POWDER (coating) inventory category.
|
// Resolve the company's POWDER (coating) inventory category.
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
|
|||||||
@@ -1263,6 +1263,10 @@ public class InventoryController : Controller
|
|||||||
if (catalogItem == null)
|
if (catalogItem == null)
|
||||||
return Json(new { success = false, error = "Catalog item not found." });
|
return Json(new { success = false, error = "Catalog item not found." });
|
||||||
|
|
||||||
|
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
|
||||||
|
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Find the default coating category to assign.
|
// Find the default coating category to assign.
|
||||||
|
|||||||
Reference in New Issue
Block a user