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:
2026-05-03 16:36:25 -04:00
parent 90f333c8f3
commit 1fc79b77fe
25 changed files with 21279 additions and 23 deletions
@@ -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>