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>
This commit is contained in:
2026-05-03 20:14:12 -04:00
parent 5e3b0b9ddf
commit 4182286a31
3 changed files with 126 additions and 0 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>
@@ -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.