diff --git a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs index 1ad72b1..92da698 100644 --- a/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs +++ b/src/PowderCoating.Application/Interfaces/IInventoryAiLookupService.cs @@ -54,4 +54,11 @@ public interface IInventoryAiLookupService /// using Claude vision. Used by the in-browser label scanner. /// Task ScanLabelAsync(string base64Image, string mediaType); + + /// + /// 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. + /// + Task FetchTdsCureSpecsAsync(string tdsUrl, string? colorName); } diff --git a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs index 5cb9397..b760b96 100644 --- a/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs +++ b/src/PowderCoating.Infrastructure/Services/InventoryAiLookupService.cs @@ -478,6 +478,108 @@ Rules: } } + /// + /// 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 + /// pipeline so JSON-LD and document links are still extracted before stripping HTML. + /// Returns = false 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. + /// + public async Task 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 + { + new Message + { + Role = RoleType.User, + Content = new List { new TextContent { Text = sb.ToString() } } + } + } + }; + + var response = await client.Messages.GetClaudeMessageAsync(messageRequest); + var rawText = (response.FirstMessage?.Text + ?? response.Content.OfType().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(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 ─────────────── /// diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 1f85b8f..0cadef0 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -834,6 +834,23 @@ public class InventoryController : Controller } } + // If cure specs are still missing but we have a TDS URL, fetch it and try to extract + // cure temperature and cure time. Most TDS pages are HTML; PDFs fail silently. + var resolvedCureTemp = catalogMatch?.CureTemperatureF ?? aiResult.CureTemperatureF; + var resolvedCureTime = catalogMatch?.CureTimeMinutes ?? aiResult.CureTimeMinutes; + var resolvedTdsUrl = catalogMatch?.TdsUrl ?? aiResult.TdsUrl; + + if ((resolvedCureTemp == null || resolvedCureTime == null) && !string.IsNullOrEmpty(resolvedTdsUrl)) + { + _logger.LogInformation("Cure specs missing after main lookup; trying TDS at {Url}", resolvedTdsUrl); + var tdsResult = await _aiLookupService.FetchTdsCureSpecsAsync(resolvedTdsUrl, colorName); + if (tdsResult.Success) + { + if (resolvedCureTemp == null) aiResult.CureTemperatureF = tdsResult.CureTemperatureF; + if (resolvedCureTime == null) aiResult.CureTimeMinutes = tdsResult.CureTimeMinutes; + } + } + // Check if this product already exists in the tenant's inventory. // Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer. // Returns the first active match so the UI can prompt to add stock inline.