Add platform powder catalog, catalog-first lookup, and label scanner
- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with full spec fields: cure temp/time, finish, color families, clear coat flag, coverage sq ft/lb, transfer efficiency, IsUserContributed - Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields - PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape, Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view with Tenant Contributed card - Unified smart Lookup button on inventory Create/Edit: catalog hit fills all fields via catalogSnapshot pattern; AI augments cure/finish data from product URL if subscription enabled; catalog miss falls through to AI lookup - In-browser label scanner (_LabelScanModal): getUserMedia live camera feed, jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends captured frame to Claude vision via /Inventory/ScanLabel - ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision path (ScanLabelAsync); auto-inserts unrecognized products as IsUserContributed=true; returns wasInCatalog/addedToCatalog flags Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -267,6 +267,201 @@ Rules:
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a powder label photo using Claude vision and extracts structured product data:
|
||||
/// manufacturer, color name, SKU, cure temperature, cure time, and finish. Used by the
|
||||
/// in-browser label scanner so shop staff can point a phone at a bag and auto-fill the
|
||||
/// inventory form without typing anything.
|
||||
/// </summary>
|
||||
public async Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType)
|
||||
{
|
||||
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
const string labelPrompt = @"This is a photo of a powder coating product label. Extract every piece of product information visible on the label.
|
||||
|
||||
Respond ONLY with a valid JSON object — no markdown, no explanation:
|
||||
|
||||
{
|
||||
""manufacturer"": ""the brand name shown on the label, e.g. 'Prismatic Powders', 'Columbia Coatings'"",
|
||||
""manufacturerPartNumber"": ""the SKU or part number printed on the label, e.g. 'PPS-1505', 'S5700001'"",
|
||||
""colorName"": ""the product color name printed on the label"",
|
||||
""colorCode"": ""RAL or NCS code if printed, otherwise null"",
|
||||
""description"": ""null — labels don't have descriptions"",
|
||||
""finish"": ""one of: Gloss, Matte, Satin, Flat, Texture, Wrinkle, Metallic, Pearl, Hammertone, Chrome — infer from the color name or any finish text on label, or null"",
|
||||
""cureTemperatureF"": ""temperature number in °F from the cure schedule printed on label — convert from °C if needed"",
|
||||
""cureTimeMinutes"": ""minutes number from the cure schedule printed on label"",
|
||||
""colorFamilies"": ""comma-separated families from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — infer from color name and bag color"",
|
||||
""requiresClearCoat"": ""true/false/null based on product name or any text on label"",
|
||||
""coverageSqFtPerLb"": null,
|
||||
""transferEfficiency"": null,
|
||||
""unitCostPerLb"": null,
|
||||
""vendorName"": ""same as manufacturer for powder labels"",
|
||||
""reasoning"": ""one sentence: what you read from the label""
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Read the label text exactly as printed — do not guess or invent SKUs or part numbers.
|
||||
- cureTemperatureF and cureTimeMinutes are almost always printed on powder labels — look carefully for 'Cure Schedule', 'Cure Time', 'Bake At', or similar text.
|
||||
- colorFamilies: infer from the color name and the visible powder/bag color in the photo.
|
||||
- If a field is not on the label and cannot be confidently inferred, use null.";
|
||||
|
||||
try
|
||||
{
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageRequest = new MessageParameters
|
||||
{
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = labelPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase>
|
||||
{
|
||||
new ImageContent
|
||||
{
|
||||
Source = new ImageSource
|
||||
{
|
||||
MediaType = mediaType,
|
||||
Data = base64Image
|
||||
}
|
||||
},
|
||||
new TextContent { Text = "Read this powder coating label and return the JSON." }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? string.Empty;
|
||||
|
||||
rawText = rawText.Trim();
|
||||
if (rawText.StartsWith("```"))
|
||||
{
|
||||
var start = rawText.IndexOf('\n') + 1;
|
||||
var end = rawText.LastIndexOf("```");
|
||||
rawText = rawText[start..end].Trim();
|
||||
}
|
||||
|
||||
if (!rawText.StartsWith("{"))
|
||||
{
|
||||
var jsonStart = rawText.IndexOf('{');
|
||||
var jsonEnd = rawText.LastIndexOf('}');
|
||||
if (jsonStart >= 0 && jsonEnd > jsonStart)
|
||||
rawText = rawText[jsonStart..(jsonEnd + 1)];
|
||||
else
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Manufacturer = GetString(parsed, "manufacturer"),
|
||||
ManufacturerPartNumber= GetString(parsed, "manufacturerPartNumber"),
|
||||
ColorName = GetString(parsed, "colorName"),
|
||||
ColorCode = GetString(parsed, "colorCode"),
|
||||
Finish = GetString(parsed, "finish"),
|
||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||||
VendorName = GetString(parsed, "vendorName"),
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during label scan AI call");
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Label scan failed: " + ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches cure specs, color families, finish, and clear-coat data directly from a
|
||||
/// known product page URL without running a Serper search. Used after a catalog hit
|
||||
/// to augment the catalog record with fields the catalog table doesn't store.
|
||||
/// </summary>
|
||||
public async Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName)
|
||||
{
|
||||
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
|
||||
|
||||
try
|
||||
{
|
||||
var (pageContent, pageImageUrl) = await FetchPageAsync(url);
|
||||
var userPrompt = BuildUserPrompt(null, colorName, null, null, new List<string>(), url, pageContent);
|
||||
|
||||
var client = new AnthropicClient(apiKey);
|
||||
var messageRequest = new MessageParameters
|
||||
{
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = ClaudeSystemPrompt,
|
||||
Messages = new List<Message>
|
||||
{
|
||||
new Message
|
||||
{
|
||||
Role = RoleType.User,
|
||||
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var response = await client.Messages.GetClaudeMessageAsync(messageRequest);
|
||||
var rawText = response.FirstMessage?.Text
|
||||
?? response.Content.OfType<TextContent>().FirstOrDefault()?.Text
|
||||
?? string.Empty;
|
||||
|
||||
rawText = rawText.Trim();
|
||||
if (rawText.StartsWith("```"))
|
||||
{
|
||||
var start = rawText.IndexOf('\n') + 1;
|
||||
var end = rawText.LastIndexOf("```");
|
||||
rawText = rawText[start..end].Trim();
|
||||
}
|
||||
|
||||
if (!rawText.StartsWith("{"))
|
||||
{
|
||||
var jsonStart = rawText.IndexOf('{');
|
||||
var jsonEnd = rawText.LastIndexOf('}');
|
||||
if (jsonStart >= 0 && jsonEnd > jsonStart)
|
||||
rawText = rawText[jsonStart..(jsonEnd + 1)];
|
||||
else
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI returned an unexpected response format." };
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||||
return new InventoryAiLookupResult
|
||||
{
|
||||
Success = true,
|
||||
Finish = GetString(parsed, "finish"),
|
||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||||
TransferEfficiency= GetDecimal(parsed, "transferEfficiency"),
|
||||
ImageUrl = pageImageUrl,
|
||||
Reasoning = GetString(parsed, "reasoning"),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during AI URL augment for {Url}", url);
|
||||
return new InventoryAiLookupResult { Success = false, ErrorMessage = "AI lookup failed: " + ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user