Compare commits

..

3 Commits

Author SHA1 Message Date
spouliot 7de65910e3 Extract shared catalog enrichment into EnrichFromCatalogAsync helper
AiLookup and ScanLabel were running separate catalog lookup + auto-contribute
code paths. Both now go through EnrichFromCatalogAsync so any future change
to catalog logic only needs to be made once.

- EnrichFromCatalogAsync: private helper that finds a matching PowderCatalogItem
  by SKU + manufacturer, overwrites AI-inferred spec fields with catalog values
  (catalog is authoritative), fills gaps for URL/price fields with ??=, and
  optionally auto-contributes new entries to the platform catalog. Returns
  (wasInCatalog, addedToCatalog) for callers that show UI badges.
- AiLookup: now calls EnrichFromCatalogAsync then ApplyTdsCureFallbackAsync
  before returning — same enrichment pipeline as ScanLabel.
- ScanLabel: replaced ~50-line inline catalog block with two helper calls.
  Return statement simplified from catalogMatch?.X ?? aiResult.X to just
  aiResult.X since EnrichFromCatalogAsync already merged catalog values in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:27:28 -04:00
spouliot 145da7b5c4 Apply TDS cure fallback and SDS/TDS URL filling to AI Lookup button
Previously these enrichments only ran in the label scanner path (ScanLabel).
The AI Lookup button and AiAugmentFromUrl went through separate code that
returned raw LookupAsync / LookupByUrlAsync results with no TDS fallback
and no SDS/TDS URL propagation to the form.

- InventoryController.ApplyTdsCureFallbackAsync: new private helper that
  checks whether cure temp or cure time is still null after the primary
  lookup, and if a TDS URL was returned calls FetchTdsCureSpecsAsync to
  fill the gap. Mutates the result in place so callers just return it.
- AiLookup: calls ApplyTdsCureFallbackAsync after LookupAsync succeeds.
- AiAugmentFromUrl: calls ApplyTdsCureFallbackAsync after LookupByUrlAsync.
- ScanLabel: replaced the inline TDS fallback block with a call to the
  same helper (merges catalog TDS URL into aiResult first so the helper
  sees the best available URL).
- _InventoryColorFamilyScripts.cshtml: added fillDocUrl() helper that fills
  field-sdsurl / field-tdsurl inputs and shows their open-link buttons when
  the AI lookup returns sdsUrl / tdsUrl. These fields existed in the form
  but were never populated by the AI Lookup button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:19:43 -04:00
spouliot 4182286a31 Fall back to TDS sheet for cure specs when main lookup returns none
After the main AI lookup and catalog search, if CureTemperatureF or
CureTimeMinutes is still null but a TDS URL was found, fetch that page
and ask Claude to extract just the cure schedule.

- IInventoryAiLookupService.FetchTdsCureSpecsAsync: new interface method
- InventoryAiLookupService.FetchTdsCureSpecsAsync: fetches the TDS URL via
  the existing FetchPageAsync pipeline (JSON-LD + doc-link extraction, HTML
  stripping). If the page is a PDF or unreachable, returns Success=false
  silently so no error surfaces in the UI. Otherwise sends a small targeted
  prompt that asks only for cureTemperatureF and cureTimeMinutes and uses
  MaxTokens=256 so the call is fast and cheap.
- InventoryController.ScanLabel: after catalog lookup, computes the resolved
  cure values (catalog preferred over AI result). If either is null and a
  TDS URL exists, calls FetchTdsCureSpecsAsync and merges any newly found
  values back into aiResult before building the JSON response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 20:14:12 -04:00
4 changed files with 253 additions and 64 deletions
@@ -54,4 +54,11 @@ public interface IInventoryAiLookupService
/// using Claude vision. Used by the in-browser label scanner.
/// </summary>
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
/// <summary>
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
/// Called when the main lookup found a TDS URL but cure specs are still missing.
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
/// </summary>
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
}
@@ -478,6 +478,108 @@ Rules:
}
}
/// <summary>
/// Fetches a TDS URL (if it is an HTML page — PDFs are silently skipped) and asks Claude
/// to extract cure temperature and cure time only. Uses the same <see cref="FetchPageAsync"/>
/// pipeline so JSON-LD and document links are still extracted before stripping HTML.
/// Returns <see cref="InventoryAiLookupResult.Success"/> = <c>false</c> without an error
/// message when the page could not be fetched (PDF, redirect loop, etc.) so the caller
/// can merge results without surfacing a user-visible error.
/// </summary>
public async Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
return new InventoryAiLookupResult { Success = false };
try
{
var (pageContent, _) = await FetchPageAsync(tdsUrl);
if (string.IsNullOrWhiteSpace(pageContent))
{
// PDF or unreachable — nothing to send to Claude
_logger.LogInformation("TDS cure lookup skipped (PDF or unreachable): {Url}", tdsUrl);
return new InventoryAiLookupResult { Success = false };
}
// 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.
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
{
""cureTemperatureF"": number or null,
""cureTimeMinutes"": number or null,
""reasoning"": ""one sentence: what cure schedule you found""
}
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 325400 °F.
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 1020 min.
- If neither value can be found in the document, return null for both.";
var sb = new StringBuilder();
sb.AppendLine("Technical Data Sheet content:");
if (!string.IsNullOrWhiteSpace(colorName)) sb.AppendLine($"Product: {colorName}");
sb.AppendLine($"Source: {tdsUrl}");
sb.AppendLine();
sb.AppendLine(pageContent);
var client = new AnthropicClient(apiKey);
var messageRequest = new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 256,
SystemMessage = curePrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = sb.ToString() } }
}
}
};
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
var rawText = (response.FirstMessage?.Text
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
?? string.Empty).Trim();
if (rawText.StartsWith("```"))
{
var start = rawText.IndexOf('\n') + 1;
var end = rawText.LastIndexOf("```");
rawText = rawText[start..end].Trim();
}
if (!rawText.StartsWith("{"))
{
var j = rawText.IndexOf('{');
var k = rawText.LastIndexOf('}');
if (j >= 0 && k > j) rawText = rawText[j..(k + 1)];
else return new InventoryAiLookupResult { Success = false };
}
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
var result = new InventoryAiLookupResult
{
Success = true,
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
Reasoning = GetString(parsed, "reasoning"),
};
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
return result;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "TDS cure spec fetch failed for {Url}", tdsUrl);
return new InventoryAiLookupResult { Success = false };
}
}
// ── Manufacturer URL pattern: build direct product page URL ───────────────
/// <summary>
@@ -679,6 +679,11 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "AI Inventory Assist is not enabled for your account. Contact your administrator." });
var result = await _aiLookupService.LookupAsync(manufacturer, colorName, colorCode, partNumber);
if (result.Success)
{
await EnrichFromCatalogAsync(result, autoContribute: true);
await ApplyTdsCureFallbackAsync(result, colorName);
}
return Json(result);
}
@@ -701,9 +706,117 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success) await ApplyTdsCureFallbackAsync(result, colorName);
return Json(result);
}
/// <summary>
/// Looks up <paramref name="result"/> in the platform powder catalog by SKU + manufacturer.
/// If a match is found, catalog values overwrite Claude-inferred ones for spec fields
/// (catalog is the authoritative source) and fill gaps for URL/price fields.
/// If no match and <paramref name="autoContribute"/> is true, inserts a new catalog entry
/// so future lookups resolve instantly without an API call.
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
/// Mutates <paramref name="result"/> in place.
/// </summary>
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
InventoryAiLookupResult result, bool autoContribute)
{
var sku = result.ManufacturerPartNumber?.Trim();
var manufacturer = (result.Manufacturer ?? result.VendorName)?.Trim();
var colorName = result.ColorName?.Trim();
PowderCatalogItem? match = null;
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
{
var skuLower = sku.ToLower();
var mfrLower = manufacturer.ToLower();
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
match = hits.FirstOrDefault();
}
if (match != null)
{
// Catalog is authoritative for spec fields — overwrite AI inference
if (match.Finish != null) result.Finish = match.Finish;
if (match.CureTemperatureF != null) result.CureTemperatureF = match.CureTemperatureF;
if (match.CureTimeMinutes != null) result.CureTimeMinutes = match.CureTimeMinutes;
if (match.ColorFamilies != null) result.ColorFamilies = match.ColorFamilies;
if (match.RequiresClearCoat != null) result.RequiresClearCoat = match.RequiresClearCoat;
if (match.CoverageSqFtPerLb != null) result.CoverageSqFtPerLb = match.CoverageSqFtPerLb;
if (match.TransferEfficiency != null) result.TransferEfficiency = match.TransferEfficiency;
// URL / price fields: fill gaps only — AI may have found something better
result.ImageUrl ??= match.ImageUrl;
result.SpecPageUrl ??= match.ProductUrl;
result.SdsUrl ??= match.SdsUrl;
result.TdsUrl ??= match.TdsUrl;
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
return (true, false);
}
if (!autoContribute
|| string.IsNullOrEmpty(sku)
|| string.IsNullOrEmpty(manufacturer)
|| string.IsNullOrEmpty(colorName))
return (false, false);
// Auto-contribute: insert into platform catalog so future lookups/scans resolve instantly
try
{
var newItem = new PowderCatalogItem
{
VendorName = manufacturer,
Sku = sku,
ColorName = colorName,
CureTemperatureF = result.CureTemperatureF,
CureTimeMinutes = result.CureTimeMinutes,
Finish = result.Finish,
ColorFamilies = result.ColorFamilies,
RequiresClearCoat = result.RequiresClearCoat,
CoverageSqFtPerLb = result.CoverageSqFtPerLb,
TransferEfficiency = result.TransferEfficiency,
ImageUrl = result.ImageUrl,
ProductUrl = result.SpecPageUrl,
SdsUrl = result.SdsUrl,
TdsUrl = result.TdsUrl,
IsUserContributed = true,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.PowderCatalog.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Auto-contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
return (false, true);
}
catch (Exception ex)
{
// Unique constraint violation means another request beat us — not an error
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
return (false, false);
}
}
/// <summary>
/// If cure temperature or cure time is still missing after the primary lookup but a TDS URL
/// was returned, fetches that page and asks Claude to extract only the cure schedule.
/// Mutates <paramref name="result"/> in place; silently no-ops on failure so callers
/// can always return the result even if the TDS fetch does not help.
/// </summary>
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
{
if ((result.CureTemperatureF == null || result.CureTimeMinutes == null)
&& !string.IsNullOrEmpty(result.TdsUrl))
{
_logger.LogInformation("Cure specs missing after lookup; trying TDS at {Url}", result.TdsUrl);
var tds = await _aiLookupService.FetchTdsCureSpecsAsync(result.TdsUrl, colorName);
if (tds.Success)
{
if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes;
}
}
}
/// <summary>
/// Accepts a base64 label photo or a decoded QR URL from the in-browser label scanner,
/// runs it through Claude (vision for photos, URL-fetch for QR), searches the platform
@@ -776,63 +889,15 @@ public class InventoryController : Controller
if (!aiResult.Success)
return Json(new { success = false, errorMessage = aiResult.ErrorMessage });
// Search catalog by SKU first (most precise), then fall back to color name
var sku = aiResult.ManufacturerPartNumber?.Trim();
var manufacturer = (aiResult.Manufacturer ?? aiResult.VendorName)?.Trim();
var colorName = aiResult.ColorName?.Trim();
PowderCatalogItem? catalogMatch = null;
if (!string.IsNullOrEmpty(sku) && !string.IsNullOrEmpty(manufacturer))
{
var skuLower = sku.ToLower();
var mfrLower = manufacturer.ToLower();
var skuMatches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
catalogMatch = skuMatches.FirstOrDefault();
}
// Catalog lookup, merge, and auto-contribute — same logic as AiLookup button
var (wasInCatalog, addedToCatalog) = await EnrichFromCatalogAsync(aiResult, autoContribute: true);
var wasInCatalog = catalogMatch != null;
var addedToCatalog = false;
// Auto-contribute: insert into platform catalog if we have the minimum viable fields
// and this SKU isn't already there
if (!wasInCatalog
&& !string.IsNullOrEmpty(sku)
&& !string.IsNullOrEmpty(manufacturer)
&& !string.IsNullOrEmpty(colorName))
{
try
{
var newItem = new PowderCatalogItem
{
VendorName = manufacturer,
Sku = sku,
ColorName = colorName,
CureTemperatureF = aiResult.CureTemperatureF,
CureTimeMinutes = aiResult.CureTimeMinutes,
Finish = aiResult.Finish,
ColorFamilies = aiResult.ColorFamilies,
RequiresClearCoat = aiResult.RequiresClearCoat,
CoverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
TransferEfficiency= aiResult.TransferEfficiency,
ImageUrl = aiResult.ImageUrl,
ProductUrl = aiResult.SpecPageUrl,
SdsUrl = aiResult.SdsUrl,
TdsUrl = aiResult.TdsUrl,
IsUserContributed = true,
CreatedAt = DateTime.UtcNow,
};
await _unitOfWork.PowderCatalog.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
addedToCatalog = true;
_logger.LogInformation("Label scan contributed new catalog entry: {Manufacturer} {Sku}", manufacturer, sku);
}
catch (Exception ex)
{
// Unique constraint violation means another request beat us — not an error
_logger.LogInformation("Catalog auto-insert skipped (likely duplicate): {Message}", ex.Message);
}
}
// TDS cure fallback — same logic as AiLookup button
await ApplyTdsCureFallbackAsync(aiResult, colorName);
// Check if this product already exists in the tenant's inventory.
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
@@ -881,18 +946,18 @@ public class InventoryController : Controller
manufacturerPartNumber = sku,
colorName = colorName,
description = aiResult.Description,
finish = catalogMatch?.Finish ?? aiResult.Finish,
cureTemperatureF = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF,
cureTimeMinutes = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes,
colorFamilies = catalogMatch?.ColorFamilies ?? aiResult.ColorFamilies,
requiresClearCoat = catalogMatch?.RequiresClearCoat ?? aiResult.RequiresClearCoat,
coverageSqFtPerLb = catalogMatch?.CoverageSqFtPerLb ?? aiResult.CoverageSqFtPerLb,
transferEfficiency = catalogMatch?.TransferEfficiency ?? aiResult.TransferEfficiency,
unitPrice = catalogMatch?.UnitPrice ?? aiResult.UnitCostPerLb ?? 0m,
imageUrl = catalogMatch?.ImageUrl ?? aiResult.ImageUrl,
productUrl = catalogMatch?.ProductUrl ?? aiResult.SpecPageUrl,
sdsUrl = catalogMatch?.SdsUrl ?? aiResult.SdsUrl,
tdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl,
finish = aiResult.Finish,
cureTemperatureF = aiResult.CureTemperatureF,
cureTimeMinutes = aiResult.CureTimeMinutes,
colorFamilies = aiResult.ColorFamilies,
requiresClearCoat = aiResult.RequiresClearCoat,
coverageSqFtPerLb = aiResult.CoverageSqFtPerLb,
transferEfficiency = aiResult.TransferEfficiency,
unitPrice = aiResult.UnitCostPerLb ?? 0m,
imageUrl = aiResult.ImageUrl,
productUrl = aiResult.SpecPageUrl,
sdsUrl = aiResult.SdsUrl,
tdsUrl = aiResult.TdsUrl,
vendorName = manufacturer,
wasInCatalog = wasInCatalog,
addedToCatalog = addedToCatalog,
@@ -450,6 +450,21 @@
aiFilledImage = true;
}
// SDS / TDS document URLs — fill inputs and show open-link buttons
const fillDocUrl = (fieldId, linkId, url, label) => {
if (!url) return;
const el = document.getElementById(fieldId);
const link = document.getElementById(linkId);
if (el && (forceRefill || !el.value.trim())) {
el.value = url;
filled.push(label);
if (!aiFilledFields.includes(fieldId)) aiFilledFields.push(fieldId);
}
if (link) { link.href = url; link.classList.remove('d-none'); }
};
fillDocUrl('field-sdsurl', 'field-sdsurl-link', data.sdsUrl, 'SDS');
fillDocUrl('field-tdsurl', 'field-tdsurl-link', data.tdsUrl, 'TDS');
// Build a persistent "needs more info" tip if key identity fields are still unknown
const missingHints = [];
if (!data.manufacturer && !document.getElementById('field-manufacturer')?.value?.trim())