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:
@@ -54,4 +54,11 @@ public interface IInventoryAiLookupService
|
|||||||
/// using Claude vision. Used by the in-browser label scanner.
|
/// using Claude vision. Used by the in-browser label scanner.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
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 ───────────────
|
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
// Check if this product already exists in the tenant's inventory.
|
||||||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
// 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.
|
// Returns the first active match so the UI can prompt to add stock inline.
|
||||||
|
|||||||
Reference in New Issue
Block a user