Compare commits
3 Commits
5e3b0b9ddf
...
7de65910e3
| Author | SHA1 | Date | |
|---|---|---|---|
| 7de65910e3 | |||
| 145da7b5c4 | |||
| 4182286a31 |
@@ -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 325–400 °F.
|
||||
- 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.";
|
||||
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user