dfb1d34af3
- Inventory: location filter dropdown + Print Bin page (line #, name, color, SKU) - Fix: Prismatic Powders QR scan now extracts manufacturer/SKU/color from URL path and uses full LookupAsync pipeline instead of relying on page fetch alone - Fix: iOS Safari 'Login / data Zero KB' download -- add OnRejected HTML response to rate limiter - Fix: mobile session logout -- ConfigureApplicationCookie with 30-day MaxAge persistent cookie - Help: new 'Location Filtering & Bin Print' section in Inventory help article - Help: HelpKnowledgeBase updated with bin filter and print bin details Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1287 lines
65 KiB
C#
1287 lines
65 KiB
C#
using System.Net.Http.Headers;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using Anthropic.SDK;
|
||
using Anthropic.SDK.Messaging;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.Logging;
|
||
using PowderCoating.Application.Interfaces;
|
||
using PowderCoating.Core.Interfaces;
|
||
|
||
namespace PowderCoating.Infrastructure.Services;
|
||
|
||
/// <summary>
|
||
/// Uses Anthropic Claude Sonnet 4.6 (via <c>AI:Anthropic:ApiKey</c>) to look up powder coating
|
||
/// product details — manufacturer, color, cure specs, pricing — from a product name, color code,
|
||
/// or part number. Results are used to pre-fill the Add Inventory Item form so shop staff
|
||
/// don't have to type technical specifications manually.
|
||
/// <para>
|
||
/// The lookup pipeline is:
|
||
/// 1. Optionally query Serper (Google Search API) for web snippets and product page URLs.
|
||
/// 2. Optionally fetch the manufacturer product page and extract JSON-LD structured data.
|
||
/// 3. Assemble a rich prompt (search snippets + page text) and call Claude.
|
||
/// 4. Parse Claude's JSON response into a typed <see cref="InventoryAiLookupResult"/>.
|
||
/// </para>
|
||
/// Serper is optional — when the <c>AI:Serper:ApiKey</c> is absent Claude falls back to its
|
||
/// training-data knowledge of powder coating product lines.
|
||
/// </summary>
|
||
public class InventoryAiLookupService : IInventoryAiLookupService
|
||
{
|
||
private const decimal DefaultTransferEfficiency = 65m;
|
||
private const decimal TheoreticalCoverageConstant = 192.3m;
|
||
private const decimal DefaultCoverageThicknessMils = 1.5m;
|
||
|
||
private readonly IConfiguration _config;
|
||
private readonly IHttpClientFactory _httpClientFactory;
|
||
private readonly ILogger<InventoryAiLookupService> _logger;
|
||
private readonly IUnitOfWork _unitOfWork;
|
||
|
||
private const string ClaudeSystemPrompt = @"You are an expert in powder coating materials with deep knowledge of manufacturer product lines.
|
||
Given web search snippets (or just the product identity if no snippets) about a specific powder coating product, extract as much structured information as possible.
|
||
Respond ONLY with a valid JSON object — no markdown, no explanation:
|
||
|
||
{
|
||
""manufacturer"": ""string or null — the brand/manufacturer name, e.g. 'Prismatic Powders', 'Tiger Drylac', 'Sherwin-Williams'"",
|
||
""manufacturerPartNumber"": ""string or null — the manufacturer's SKU/part number for this specific color/product"",
|
||
""colorName"": ""string or null — official product color name"",
|
||
""colorCode"": ""string or null — RAL, NCS, or manufacturer color code"",
|
||
""description"": ""string or null — one concise sentence describing THIS SPECIFIC COLOR AND ITS VISUAL EFFECT, not the manufacturer or retailer. E.g. 'Deep burgundy color-shifting powder that transitions from red to purple under different lighting.' Never describe the company."",
|
||
""finish"": ""one of: Gloss, Matte, Satin, Flat, Texture, Wrinkle, Metallic, Pearl, Hammertone, Chrome, or null"",
|
||
""cureTemperatureF"": number or null,
|
||
""cureTimeMinutes"": number or null,
|
||
""colorFamilies"": ""comma-separated list from: Red,Orange,Yellow,Green,Blue,Purple,Pink,Brown,Black,White,Gray,Silver,Gold,Bronze,Copper,Clear — or null if unknown"",
|
||
""requiresClearCoat"": true or false or null,
|
||
""specificGravity"": number or null,
|
||
""coverageSqFtPerLb"": number or null,
|
||
""transferEfficiency"": number or null,
|
||
""unitCostPerLb"": number or null,
|
||
""vendorName"": ""string or null — the retailer or distributor name if a price was found (not the manufacturer)"",
|
||
""sdsUrl"": ""full URL to the Safety Data Sheet (SDS/MSDS) if found in the page content or links — null if not found"",
|
||
""tdsUrl"": ""full URL to the Technical Data Sheet (TDS/Spec Sheet) if found in the page content or links — null if not found"",
|
||
""reasoning"": ""one sentence: what specific product data was found and how confident you are""
|
||
}
|
||
|
||
Rules:
|
||
- description: MUST describe the specific color's appearance and any special effects (color shift, metallic flake, etc.). NEVER describe the brand, company, or product line in general. If the color shifts or changes under light, say so.
|
||
- cureTemperatureF: convert to °F if given in °C (multiply by 1.8 + 32). Most powder coatings cure 325-400°F. If a range is given, use the midpoint.
|
||
- cureTimeMinutes: hold time at cure temperature (not total oven time). Typical 10-20 min.
|
||
- colorFamilies: pick the 1-2 best matching families from the allowed list. E.g. 'Teal' → 'Green,Blue', 'Rose Gold' → 'Pink,Gold', 'Bronze' → 'Bronze,Brown'. Color-shifting powders: use the base/dominant hue(s).
|
||
- requiresClearCoat: set to TRUE in any of these cases:
|
||
* Product explicitly states a clear coat is required or recommended
|
||
* Product says a clear coat is needed to ""activate"" the color or effect
|
||
* Product says a clear coat ""brings out"" the metallic, pearl, or color-shift effect
|
||
* Product is a chameleon, color-shift, interference, or dormant powder (these almost always need clear coat)
|
||
* Product name contains ""Illusion"", ""Chameleon"", ""Shift"", ""Ghost"", ""Dormant"", or similar effect names
|
||
* ""Dormant"" powders are specially formulated to look flat/dull until a clear coat activates their true color — always true
|
||
* Product requires a base coat AND a clear coat for full effect
|
||
Set to false only if the product explicitly states no clear coat needed. Use null if genuinely unknown.
|
||
- coverageSqFtPerLb: theoretical coverage in sq ft per pound. Typical range 80-120 sq ft/lb. If given in metric (m²/kg), multiply by 4.88 to convert.
|
||
- transferEfficiency: percentage (0-100) of powder that adheres to the part. Typical 60-75%. Use null if not found — do NOT guess this.
|
||
- unitCostPerLb: price per pound (or per unit) if found in search results OR in the [Structured Data] block. Only return if an actual price is clearly stated — do NOT estimate or guess. Return as a plain number (e.g. 12.99). The [Structured Data] price block is machine-readable and highly reliable — prefer it over prose snippets.
|
||
- vendorName: if a price was found, also return the name of the retailer/distributor where that price was found (e.g. ""Prismatic Powders"", ""Powder Buy the Pound""). This helps match the vendor dropdown.
|
||
- manufacturer: return the brand name if you know it from your training data or search results. E.g. if the color name is well-known from a specific brand, return that brand.
|
||
- manufacturerPartNumber: return the SKU/item number if you know it from your training data or search results. Do not guess or invent one.
|
||
Each brand labels their SKU differently — extract these patterns:
|
||
* Prismatic Powders: labeled ""Item:"" followed by a code like PMB-6906
|
||
* Columbia Coatings: labeled ""SKU:"" followed by a code like CS1534083
|
||
* Tiger Drylac: SKU appears before the color name, formatted like ""149/30065 Pastel Pink"" — extract only the numeric portion (e.g. ""149/30065"")
|
||
* All Powder Paints: labeled ""Product ID:"" followed by a code like PSGGR833990X
|
||
* Interpon (by AkzoNobel): labeled ""Code"" followed by an alphanumeric code like G2243QF. If the manufacturer field is empty and the product is Interpon, also set manufacturer to ""AkzoNobel / Interpon"".
|
||
* Cardinal Paint & Powder: alphanumeric codes formatted like C241-BK01 (letter-digits-dash-letters-digits pattern).
|
||
* PPG Industries: labeled ""SKU:"" followed by a code like PCTT99259-55 (letters then digits, often with a dash and size suffix).
|
||
* Powder Buy the Pound: labeled ""SKU:"" followed by a code like SK16211 (letters then digits, no dash).
|
||
* Sherwin-Williams (Powdura): SKU appears on its own line directly below the color name, formatted like EGS2-90007 (letters-digits-dash-digits). There is no label prefix — it just follows the product name.
|
||
* Cerakote: labeled ""Item:"" followed by a short code like F-122 (letter-dash-digits).
|
||
* Other brands: look for ""SKU"", ""Item #"", ""Part #"", ""Product Code"", ""Product ID"", ""Code"", or similar labels
|
||
- colorCode: RAL code (e.g. RAL 9005), NCS code, or manufacturer's own color code. Return if known — do not infer from the color name alone.
|
||
- sdsUrl: look for links or text labeled ""SDS"", ""Safety Data Sheet"", ""MSDS"". If a [Structured Data] SDS URL line is present, use it. Return the full URL or null.
|
||
- tdsUrl: look for links or text labeled ""TDS"", ""Technical Data Sheet"", ""Spec Sheet"", ""Data Sheet"". If a [Structured Data] TDS URL line is present, use it. Return the full URL or null.
|
||
- If a field cannot be confidently determined, use null.";
|
||
|
||
public InventoryAiLookupService(
|
||
IConfiguration config,
|
||
IHttpClientFactory httpClientFactory,
|
||
ILogger<InventoryAiLookupService> logger,
|
||
IUnitOfWork unitOfWork)
|
||
{
|
||
_config = config;
|
||
_httpClientFactory = httpClientFactory;
|
||
_logger = logger;
|
||
_unitOfWork = unitOfWork;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Main entry point: resolves powder product details from any combination of the four
|
||
/// identifying inputs. At least one non-empty input is required; all four are optional
|
||
/// individually so callers can pass whatever the user has already typed.
|
||
/// <para>
|
||
/// The method loads <c>ManufacturerLookupPattern</c> records with
|
||
/// <c>ignoreQueryFilters: true</c> because those patterns are platform-global (not
|
||
/// tenant-scoped), so the normal company isolation filter must be bypassed.
|
||
/// </para>
|
||
/// Returns <see cref="InventoryAiLookupResult.Success"/> = <c>false</c> (never throws)
|
||
/// on API errors so the calling controller can present a friendly message.
|
||
/// </summary>
|
||
public async Task<InventoryAiLookupResult> LookupAsync(
|
||
string? manufacturer,
|
||
string? colorName,
|
||
string? colorCode,
|
||
string? partNumber)
|
||
{
|
||
var parts = new List<string?> { manufacturer, colorName, colorCode, partNumber }
|
||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||
.ToList();
|
||
|
||
if (parts.Count == 0)
|
||
{
|
||
return new InventoryAiLookupResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "Please provide at least one of: manufacturer, color name, color code, or part number."
|
||
};
|
||
}
|
||
|
||
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
|
||
{
|
||
// Load all active manufacturer patterns (global records, ignoreQueryFilters bypasses tenant filter)
|
||
var allPatterns = await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true);
|
||
var activePatterns = allPatterns.Where(p => p.IsActive).ToList();
|
||
|
||
// Build search query
|
||
var query = string.Join(" ", parts) + " powder coating cure temperature specifications";
|
||
_logger.LogInformation("AI inventory lookup for: {Query}", query);
|
||
|
||
// Try Serper search first; fall back to empty snippets if not configured
|
||
// Pass active patterns so domains are used for URL selection
|
||
// searchFetchUrl may be a TDS page (preferred for data); searchProductUrl is the product page
|
||
var (snippets, searchFetchUrl, searchProductUrl) = await SearchSerperAsync(query, colorName, colorCode, partNumber, activePatterns);
|
||
|
||
// Try to build a direct product page URL from manufacturer pattern (most reliable)
|
||
var directUrl = TryBuildDirectUrl(manufacturer, colorName, partNumber, activePatterns);
|
||
|
||
// Fetching: prefer direct URL → TDS/search URL (best data source)
|
||
var fetchUrl = directUrl ?? searchFetchUrl;
|
||
|
||
// SpecPageUrl: always point to the actual product page, never a TDS
|
||
// direct URL is always a product page; fall back to searchProductUrl (not TDS)
|
||
var specPageUrl = directUrl ?? searchProductUrl;
|
||
|
||
if (directUrl != null)
|
||
_logger.LogInformation("Using direct manufacturer URL: {Url}", directUrl);
|
||
|
||
// Fetch product page
|
||
string? pageContent = null;
|
||
string? pageImageUrl = null;
|
||
if (fetchUrl != null)
|
||
{
|
||
(pageContent, pageImageUrl) = await FetchPageAsync(fetchUrl);
|
||
}
|
||
|
||
// If direct URL fetch failed, fall back to the search fetch URL
|
||
if (pageContent == null && directUrl != null && searchFetchUrl != null && searchFetchUrl != directUrl)
|
||
{
|
||
_logger.LogInformation("Direct URL fetch failed; falling back to search URL: {Url}", searchFetchUrl);
|
||
fetchUrl = searchFetchUrl;
|
||
(pageContent, pageImageUrl) = await FetchPageAsync(searchFetchUrl);
|
||
}
|
||
|
||
var userPrompt = BuildUserPrompt(manufacturer, colorName, colorCode, partNumber, snippets, fetchUrl, 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;
|
||
|
||
// Strip markdown code fences if present
|
||
rawText = rawText.Trim();
|
||
if (rawText.StartsWith("```"))
|
||
{
|
||
var start = rawText.IndexOf('\n') + 1;
|
||
var end = rawText.LastIndexOf("```");
|
||
rawText = rawText[start..end].Trim();
|
||
}
|
||
|
||
// If Claude returned prose instead of JSON, try to extract the JSON object
|
||
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. Please try again."
|
||
};
|
||
}
|
||
|
||
var parsed = JsonSerializer.Deserialize<JsonElement>(rawText);
|
||
|
||
var result = new InventoryAiLookupResult { Success = true };
|
||
result.Manufacturer = GetString(parsed, "manufacturer");
|
||
result.ManufacturerPartNumber = GetString(parsed, "manufacturerPartNumber");
|
||
result.ColorName = GetString(parsed, "colorName");
|
||
result.ColorCode = GetString(parsed, "colorCode");
|
||
result.Description = GetString(parsed, "description");
|
||
result.Finish = GetString(parsed, "finish");
|
||
result.CureTemperatureF = GetDecimal(parsed, "cureTemperatureF");
|
||
result.CureTimeMinutes = GetInt(parsed, "cureTimeMinutes");
|
||
result.ColorFamilies = GetString(parsed, "colorFamilies");
|
||
result.RequiresClearCoat = GetBool(parsed, "requiresClearCoat");
|
||
result.SpecificGravity = GetDecimal(parsed, "specificGravity");
|
||
result.CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb");
|
||
result.TransferEfficiency = GetDecimal(parsed, "transferEfficiency");
|
||
result.UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb");
|
||
result.VendorName = GetString(parsed, "vendorName");
|
||
result.SdsUrl = GetString(parsed, "sdsUrl");
|
||
result.TdsUrl = GetString(parsed, "tdsUrl");
|
||
result.SpecPageUrl = specPageUrl;
|
||
result.ImageUrl = pageImageUrl;
|
||
result.Reasoning = GetString(parsed, "reasoning");
|
||
|
||
ApplyPowderFallbacks(result);
|
||
return result;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogError(ex, "Error during AI inventory lookup");
|
||
return new InventoryAiLookupResult
|
||
{
|
||
Success = false,
|
||
ErrorMessage = "AI lookup failed: " + ex.Message
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <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);
|
||
var result = 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"),
|
||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||
VendorName = GetString(parsed, "vendorName"),
|
||
Reasoning = GetString(parsed, "reasoning"),
|
||
};
|
||
|
||
ApplyPowderFallbacks(result);
|
||
return result;
|
||
}
|
||
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, string? tdsFallbackUrl = null)
|
||
{
|
||
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);
|
||
var result = new InventoryAiLookupResult
|
||
{
|
||
Success = true,
|
||
Manufacturer = GetString(parsed, "manufacturer"),
|
||
ManufacturerPartNumber = GetString(parsed, "manufacturerPartNumber"),
|
||
ColorName = GetString(parsed, "colorName"),
|
||
ColorCode = GetString(parsed, "colorCode"),
|
||
Description = GetString(parsed, "description"),
|
||
Finish = GetString(parsed, "finish"),
|
||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||
ColorFamilies = GetString(parsed, "colorFamilies"),
|
||
RequiresClearCoat = GetBool(parsed, "requiresClearCoat"),
|
||
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||
CoverageSqFtPerLb = GetDecimal(parsed, "coverageSqFtPerLb"),
|
||
TransferEfficiency = GetDecimal(parsed, "transferEfficiency"),
|
||
UnitCostPerLb = GetDecimal(parsed, "unitCostPerLb"),
|
||
VendorName = GetString(parsed, "vendorName"),
|
||
SdsUrl = GetString(parsed, "sdsUrl"),
|
||
TdsUrl = GetString(parsed, "tdsUrl"),
|
||
SpecPageUrl = url,
|
||
ImageUrl = pageImageUrl,
|
||
Reasoning = GetString(parsed, "reasoning"),
|
||
};
|
||
|
||
ApplyPowderFallbacks(result);
|
||
|
||
// TDS fallback: use the TDS URL discovered from the product page, or the one the
|
||
// caller passed in (e.g. known from catalog). Try it when cure specs are still missing.
|
||
var effectiveTdsUrl = result.TdsUrl ?? tdsFallbackUrl;
|
||
if (!string.IsNullOrWhiteSpace(effectiveTdsUrl) &&
|
||
(result.CureTemperatureF == null || result.CureTimeMinutes == null))
|
||
{
|
||
try
|
||
{
|
||
var tds = await FetchTdsCureSpecsAsync(effectiveTdsUrl!, colorName);
|
||
if (tds.Success)
|
||
{
|
||
if (result.CureTemperatureF == null) result.CureTemperatureF = tds.CureTemperatureF;
|
||
if (result.CureTimeMinutes == null) result.CureTimeMinutes = tds.CureTimeMinutes;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "TDS fallback failed for {Url}", tdsFallbackUrl);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
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 };
|
||
}
|
||
}
|
||
|
||
/// <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 ───────────────
|
||
|
||
/// <summary>
|
||
/// Attempts to construct a direct product page URL for a known manufacturer using
|
||
/// a <c>ProductUrlTemplate</c> stored in the <c>ManufacturerLookupPattern</c> DB table.
|
||
/// Templates contain <c>{partNumber}</c>, <c>{slug}</c>, or <c>{colorCode}</c> placeholders
|
||
/// that are replaced with the caller-supplied values.
|
||
/// <para>
|
||
/// Returns <c>null</c> when the manufacturer is unknown, the template requires a value
|
||
/// (e.g., part number) that was not supplied, or no matching pattern exists in the DB.
|
||
/// </para>
|
||
/// Slashes in part numbers are converted to hyphens because forward slashes in URL path
|
||
/// segments break most web servers (e.g., Tiger Drylac's "149/30065" pattern).
|
||
/// </summary>
|
||
private static string? TryBuildDirectUrl(
|
||
string? manufacturer, string? colorName, string? partNumber,
|
||
List<Core.Entities.ManufacturerLookupPattern> patterns)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(manufacturer) || patterns.Count == 0) return null;
|
||
|
||
var mfrLower = manufacturer.ToLowerInvariant();
|
||
var pattern = patterns.FirstOrDefault(p =>
|
||
mfrLower.Contains(p.ManufacturerName.ToLowerInvariant()) ||
|
||
p.ManufacturerName.ToLowerInvariant().Contains(mfrLower));
|
||
|
||
if (pattern?.ProductUrlTemplate == null) return null;
|
||
|
||
var template = pattern.ProductUrlTemplate;
|
||
|
||
// Resolve {partNumber} placeholder
|
||
if (template.Contains("{partNumber}", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(partNumber)) return null; // can't build without it
|
||
// Normalize slashes in part numbers to hyphens for URL safety
|
||
var pn = partNumber.Replace("/", "-").Replace("\\", "-");
|
||
template = template.Replace("{partNumber}", pn, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
// Resolve {slug} / {colorName} placeholder
|
||
if (template.Contains("{slug}", StringComparison.OrdinalIgnoreCase) ||
|
||
template.Contains("{colorName}", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(colorName)) return null; // can't build without it
|
||
var slug = MakeSlug(colorName, pattern.SlugTransform);
|
||
template = template
|
||
.Replace("{slug}", slug, StringComparison.OrdinalIgnoreCase)
|
||
.Replace("{colorName}", slug, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
// Resolve {colorCode} placeholder
|
||
if (template.Contains("{colorCode}", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
template = template.Replace("{colorCode}", colorName ?? "", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
return template;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Converts a human-readable color name to the URL slug format expected by a specific
|
||
/// manufacturer's website, as specified by the <c>SlugTransform</c> field on the pattern.
|
||
/// Supported transforms: <c>LowerHyphen</c> (e.g., "fire-red"), <c>LowerUnderscore</c>
|
||
/// (e.g., "fire_red"), <c>TitleHyphen</c> (e.g., "Fire-Red"), and <c>AsIs</c> (default,
|
||
/// replaces spaces with hyphens without case change).
|
||
/// </summary>
|
||
private static string MakeSlug(string input, string transform) =>
|
||
transform switch
|
||
{
|
||
"LowerHyphen" => input.ToLowerInvariant().Replace(" ", "-"),
|
||
"LowerUnderscore"=> input.ToLowerInvariant().Replace(" ", "_"),
|
||
"TitleHyphen" => string.Join("-", input.Split(' ')
|
||
.Select(w => w.Length > 0
|
||
? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant()
|
||
: "")),
|
||
_ => input.Replace(" ", "-") // AsIs
|
||
};
|
||
|
||
// ── Serper search ─────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Queries the Serper Google Search API for product snippets and attempts to identify
|
||
/// two distinct URLs from the results:
|
||
/// <list type="bullet">
|
||
/// <item><description><c>fetchUrl</c> — the best URL to fetch full page content from;
|
||
/// prefers a Technical Data Sheet (TDS) page because it contains machine-readable cure
|
||
/// specs, falling back to the first known-manufacturer product page.</description></item>
|
||
/// <item><description><c>productUrl</c> — always the human-facing product page URL
|
||
/// (never a TDS), used as <c>SpecPageUrl</c> in the result for the UI to link to.</description></item>
|
||
/// </list>
|
||
/// Returns empty snippets and null URLs when Serper is not configured, so the caller
|
||
/// transparently falls back to Claude's training knowledge.
|
||
/// <para>
|
||
/// Relevance filtering uses <paramref name="colorName"/>, <paramref name="colorCode"/>,
|
||
/// and <paramref name="partNumber"/> (NOT manufacturer name) as search terms to reject
|
||
/// manufacturer URLs that match the domain but point to a different product.
|
||
/// </para>
|
||
/// </summary>
|
||
private async Task<(List<string> snippets, string? fetchUrl, string? productUrl)> SearchSerperAsync(
|
||
string query, string? colorName, string? colorCode, string? partNumber,
|
||
List<Core.Entities.ManufacturerLookupPattern> patterns)
|
||
{
|
||
var serperApiKey = _config["AI:Serper:ApiKey"];
|
||
if (string.IsNullOrWhiteSpace(serperApiKey) || serperApiKey.StartsWith("your-"))
|
||
{
|
||
_logger.LogInformation("Serper API key not configured; Claude will use prior knowledge only.");
|
||
return (new List<string>(), null, null);
|
||
}
|
||
|
||
try
|
||
{
|
||
var client = _httpClientFactory.CreateClient();
|
||
client.DefaultRequestHeaders.Add("X-API-KEY", serperApiKey);
|
||
|
||
var body = JsonSerializer.Serialize(new { q = query, num = 5 });
|
||
var content = new StringContent(body, Encoding.UTF8, "application/json");
|
||
|
||
var response = await client.PostAsync("https://google.serper.dev/search", content);
|
||
if (!response.IsSuccessStatusCode)
|
||
{
|
||
_logger.LogWarning("Serper returned {Status}", response.StatusCode);
|
||
return (new List<string>(), null, null);
|
||
}
|
||
|
||
var json = await response.Content.ReadAsStringAsync();
|
||
var doc = JsonDocument.Parse(json);
|
||
|
||
var snippets = new List<string>();
|
||
string? tdsUrl = null;
|
||
string? productUrl = null;
|
||
|
||
// Build relevance terms from product-specific inputs ONLY (not manufacturer or generic
|
||
// words like "powder coating") so that a manufacturer URL for the wrong product is rejected.
|
||
var productTerms = new[] { colorName, colorCode, partNumber }
|
||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||
.SelectMany(s => s!.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||
.Where(w => w.Length >= 4)
|
||
.Select(w => w.ToLowerInvariant())
|
||
.ToHashSet();
|
||
|
||
bool IsRelevantToSearch(string? link, string? title)
|
||
{
|
||
if (productTerms.Count == 0) return true;
|
||
var haystack = ((link ?? "") + " " + (title ?? "")).ToLowerInvariant();
|
||
return productTerms.Any(term => haystack.Contains(term));
|
||
}
|
||
|
||
// Combine known domains: DB patterns + hardcoded fallback set
|
||
var knownDomains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var p in patterns.Where(p => !string.IsNullOrWhiteSpace(p.Domain)))
|
||
knownDomains.Add(p.Domain!);
|
||
// Hardcoded fallback for manufacturers not yet in DB
|
||
foreach (var d in new[] {
|
||
"prismaticpowders.com", "tiger-coatings.com", "tigerdrylac.com",
|
||
"sherwin-williams.com", "columbiacoatings.com", "allpowderpaints.com",
|
||
"powderbythepound.com", "cardinalpp.com", "ppg.com", "cerakote.com",
|
||
"interpon.com", "akzonobel.com" })
|
||
knownDomains.Add(d);
|
||
|
||
if (doc.RootElement.TryGetProperty("organic", out var organic))
|
||
{
|
||
foreach (var item in organic.EnumerateArray())
|
||
{
|
||
var title = item.TryGetProperty("title", out var t) ? t.GetString() : null;
|
||
var snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() : null;
|
||
var link = item.TryGetProperty("link", out var l) ? l.GetString() : null;
|
||
|
||
var sb = new StringBuilder();
|
||
if (!string.IsNullOrWhiteSpace(title)) sb.Append(title).Append(": ");
|
||
if (!string.IsNullOrWhiteSpace(snippet)) sb.Append(snippet);
|
||
if (sb.Length > 0) snippets.Add(sb.ToString());
|
||
|
||
if (!string.IsNullOrWhiteSpace(link) &&
|
||
!link.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase) &&
|
||
IsRelevantToSearch(link, title))
|
||
{
|
||
var lowerLink = link.ToLowerInvariant();
|
||
var lowerTitle = (title ?? "").ToLowerInvariant();
|
||
|
||
if (tdsUrl == null && (
|
||
lowerLink.Contains("tech-data") || lowerLink.Contains("/tds") ||
|
||
lowerTitle.Contains("tech data") || lowerTitle.Contains("technical data")))
|
||
{
|
||
tdsUrl = link;
|
||
}
|
||
else if (productUrl == null)
|
||
{
|
||
try
|
||
{
|
||
var host = new Uri(link).Host.Replace("www.", "");
|
||
if (knownDomains.Contains(host))
|
||
productUrl = link;
|
||
}
|
||
catch { /* ignore bad URLs */ }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
var fetchUrl = tdsUrl ?? productUrl;
|
||
_logger.LogInformation("Serper returned {Count} snippets; fetch URL: {FetchUrl}, product URL: {ProductUrl}",
|
||
snippets.Count, fetchUrl ?? "none", productUrl ?? "none");
|
||
return (snippets, fetchUrl, productUrl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Serper search failed; proceeding with Claude knowledge only");
|
||
return (new List<string>(), null, null);
|
||
}
|
||
}
|
||
|
||
// ── Page fetching with JSON-LD extraction ─────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Downloads a product or TDS page and converts it to plain text suitable for Claude.
|
||
/// <para>
|
||
/// JSON-LD structured data blocks (<c>application/ld+json</c>) are extracted BEFORE
|
||
/// script/style tags are stripped because many Shopify/WooCommerce product pages embed
|
||
/// machine-readable price and SKU data in JSON-LD that would otherwise be silently
|
||
/// discarded with the script content. The extracted data is prepended with
|
||
/// "[Structured Data]" markers so Claude treats it as high-confidence information.
|
||
/// </para>
|
||
/// Page text is capped at 3,500 characters to leave room for the structured data header
|
||
/// and the rest of the prompt within Claude's context window.
|
||
/// Returns <c>null</c> on any HTTP or parsing error so the caller falls back gracefully.
|
||
/// A browser-like User-Agent header is sent because some manufacturer sites return 403
|
||
/// or empty responses to bare HttpClient default agents.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Fetches a product page and returns both stripped plain text (for Claude) and the
|
||
/// best product image URL found on the page. Extracts og:image (Open Graph) first,
|
||
/// then falls back to twitter:image. The raw HTML is processed before tag-stripping
|
||
/// so the image URL is captured while it still exists in the markup.
|
||
/// </summary>
|
||
private async Task<(string? text, string? imageUrl)> FetchPageAsync(string url)
|
||
{
|
||
try
|
||
{
|
||
var client = _httpClientFactory.CreateClient();
|
||
client.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36");
|
||
client.Timeout = TimeSpan.FromSeconds(10);
|
||
|
||
var html = await client.GetStringAsync(url);
|
||
|
||
// Extract product image from Open Graph / Twitter Card meta tags
|
||
var imageUrl = ExtractOgImageUrl(html);
|
||
|
||
// Extract SDS/TDS document links BEFORE stripping HTML so hrefs aren't lost.
|
||
var docLinks = ExtractDocumentLinks(html, url);
|
||
|
||
// Extract structured data (JSON-LD) BEFORE stripping scripts — it contains
|
||
// machine-readable price, SKU, and product info that would otherwise be lost.
|
||
var structuredData = ExtractJsonLdData(html);
|
||
|
||
// Extract visible price text BEFORE stripping HTML as a fallback when
|
||
// JSON-LD is absent or incomplete (e.g. JS-rendered stores).
|
||
var htmlPriceSnippet = ExtractHtmlPriceSnippet(html);
|
||
|
||
// Remove script/style blocks
|
||
html = System.Text.RegularExpressions.Regex.Replace(
|
||
html, @"<(script|style)[^>]*>[\s\S]*?</(script|style)>", "",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
||
// Strip remaining HTML tags
|
||
var text = System.Text.RegularExpressions.Regex.Replace(html, @"<[^>]+>", " ");
|
||
|
||
// Collapse whitespace and decode HTML entities
|
||
text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ").Trim();
|
||
text = System.Net.WebUtility.HtmlDecode(text);
|
||
|
||
// Limit page text to 3500 chars to leave room for structured data header
|
||
const int maxChars = 3500;
|
||
if (text.Length > maxChars)
|
||
text = text[..maxChars] + "…";
|
||
|
||
// Prepend structured data + document links + price fallback — Claude treats these as high-confidence
|
||
var header = new StringBuilder();
|
||
if (!string.IsNullOrWhiteSpace(structuredData)) header.Append(structuredData);
|
||
if (!string.IsNullOrWhiteSpace(htmlPriceSnippet)) header.Append(htmlPriceSnippet);
|
||
if (!string.IsNullOrWhiteSpace(docLinks)) header.Append(docLinks);
|
||
if (header.Length > 0) text = header + "\n" + text;
|
||
|
||
_logger.LogInformation("Fetched {Chars} chars from {Url} (structured data: {HasData}, image: {HasImage})",
|
||
text.Length, url, structuredData != null ? "yes" : "no", imageUrl != null ? "yes" : "no");
|
||
return (text, imageUrl);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to fetch page content from {Url}", url);
|
||
return (null, null);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts the best product image URL from raw HTML. Checks og:image first (most
|
||
/// reliable for e-commerce product pages), then twitter:image as fallback.
|
||
/// </summary>
|
||
private static string? ExtractOgImageUrl(string html)
|
||
{
|
||
var patterns = new[]
|
||
{
|
||
@"<meta[^>]+property=[""']og:image[""'][^>]+content=[""']([^""']+)[""']",
|
||
@"<meta[^>]+content=[""']([^""']+)[""'][^>]+property=[""']og:image[""']",
|
||
@"<meta[^>]+name=[""']twitter:image[""'][^>]+content=[""']([^""']+)[""']",
|
||
@"<meta[^>]+content=[""']([^""']+)[""'][^>]+name=[""']twitter:image[""']",
|
||
};
|
||
|
||
foreach (var pattern in patterns)
|
||
{
|
||
var m = System.Text.RegularExpressions.Regex.Match(
|
||
html, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (m.Success)
|
||
{
|
||
var url = m.Groups[1].Value.Trim();
|
||
if (url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||
return url;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Scans raw HTML for visible price elements — common WooCommerce/Shopify price class
|
||
/// names and itemprop="price" microdata — and returns them as a "[Page Price]" header
|
||
/// line. This runs on the full, untruncated HTML before tag stripping so prices that
|
||
/// would fall past the 3,500-char page-text cutoff are still surfaced to Claude.
|
||
/// Only used when JSON-LD structured data didn't already yield a price.
|
||
/// </summary>
|
||
private static string? ExtractHtmlPriceSnippet(string html)
|
||
{
|
||
// itemprop="price" microdata (e.g. <span itemprop="price">12.99</span>)
|
||
var microprice = System.Text.RegularExpressions.Regex.Match(
|
||
html,
|
||
@"itemprop=[""']price[""'][^>]*content=[""']([0-9]+\.?[0-9]*)[""']|" +
|
||
@"itemprop=[""']price[""'][^>]*>[\s]*\$?([0-9]+\.?[0-9]*)",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (microprice.Success)
|
||
{
|
||
var val = (microprice.Groups[1].Value.Trim().Length > 0
|
||
? microprice.Groups[1].Value
|
||
: microprice.Groups[2].Value).Trim();
|
||
if (!string.IsNullOrEmpty(val))
|
||
return $"[Page Price] Price found in page microdata: ${val}\n";
|
||
}
|
||
|
||
// WooCommerce / common e-commerce price class names
|
||
var classPricePattern = System.Text.RegularExpressions.Regex.Match(
|
||
html,
|
||
@"class=[""'][^""']*(?:price|woocommerce-Price-amount|product-price|bdi)[^""']*[""'][^>]*>" +
|
||
@"[\s\S]{0,60}?\$\s*([0-9]+\.[0-9]{2})",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
if (classPricePattern.Success)
|
||
{
|
||
var val = classPricePattern.Groups[1].Value.Trim();
|
||
if (!string.IsNullOrEmpty(val))
|
||
return $"[Page Price] Price found in page HTML: ${val}\n";
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Scans raw HTML for anchor tags linking to SDS or TDS documents and returns them as
|
||
/// "[Structured Data]" lines that Claude can read and echo back in its JSON response.
|
||
/// Resolves relative hrefs to absolute URLs using the page's base URL. Stops after
|
||
/// finding one SDS and one TDS to avoid returning irrelevant links.
|
||
/// </summary>
|
||
private static string? ExtractDocumentLinks(string html, string pageUrl)
|
||
{
|
||
Uri? baseUri = null;
|
||
try { baseUri = new Uri(pageUrl); } catch { }
|
||
|
||
var sb = new StringBuilder();
|
||
string? sdsUrl = null, tdsUrl = null;
|
||
|
||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||
html,
|
||
@"<a\s+[^>]*href=[""']([^""'#][^""']*)[""'][^>]*>([\s\S]*?)</a>",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
||
foreach (System.Text.RegularExpressions.Match m in matches)
|
||
{
|
||
if (sdsUrl != null && tdsUrl != null) break;
|
||
|
||
var href = m.Groups[1].Value.Trim();
|
||
var linkText = System.Text.RegularExpressions.Regex
|
||
.Replace(m.Groups[2].Value, @"<[^>]+>", "").Trim();
|
||
|
||
// Resolve relative hrefs to absolute
|
||
string absHref = href;
|
||
if (baseUri != null && !href.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
try { absHref = new Uri(baseUri, href).ToString(); } catch { continue; }
|
||
}
|
||
if (!absHref.StartsWith("http", StringComparison.OrdinalIgnoreCase)) continue;
|
||
|
||
var hrefL = href.ToLowerInvariant();
|
||
var textL = linkText.ToLowerInvariant();
|
||
|
||
if (sdsUrl == null &&
|
||
(textL.Contains("sds") || textL.Contains("safety data") || textL.Contains("msds") ||
|
||
hrefL.Contains("sds") || hrefL.Contains("safety") || hrefL.Contains("msds")))
|
||
{
|
||
sdsUrl = absHref;
|
||
sb.AppendLine($"[Structured Data] SDS URL: {absHref}");
|
||
}
|
||
else if (tdsUrl == null &&
|
||
(textL.Contains("tds") || textL.Contains("technical data") || textL.Contains("spec sheet") ||
|
||
textL.Contains("data sheet") || hrefL.Contains("/tds") || hrefL.Contains("technical-data") ||
|
||
hrefL.Contains("techdata") || hrefL.Contains("datasheet")))
|
||
{
|
||
tdsUrl = absHref;
|
||
sb.AppendLine($"[Structured Data] TDS URL: {absHref}");
|
||
}
|
||
}
|
||
|
||
return sb.Length > 0 ? sb.ToString() : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Extracts product name, SKU, and price from JSON-LD structured data blocks.
|
||
/// Many e-commerce sites (Shopify, WooCommerce, etc.) embed this in the page HTML
|
||
/// even when the visible price is rendered by JavaScript.
|
||
/// <para>
|
||
/// Handles three common JSON-LD shapes:
|
||
/// - Single Product object: <c>{"@type":"Product",...}</c>
|
||
/// - Top-level array: <c>[{"@type":"Product",...},...]</c>
|
||
/// - WooCommerce/Yoast @graph wrapper: <c>{"@graph":[{"@type":"Product",...},...]}</c>
|
||
/// </para>
|
||
/// </summary>
|
||
private static string? ExtractJsonLdData(string html)
|
||
{
|
||
var sb = new StringBuilder();
|
||
var matches = System.Text.RegularExpressions.Regex.Matches(
|
||
html,
|
||
@"<script[^>]+type=[""']application/ld\+json[""'][^>]*>([\s\S]*?)</script>",
|
||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||
|
||
foreach (System.Text.RegularExpressions.Match m in matches)
|
||
{
|
||
try
|
||
{
|
||
var jsonText = m.Groups[1].Value.Trim();
|
||
using var doc = JsonDocument.Parse(jsonText);
|
||
var root = doc.RootElement;
|
||
|
||
foreach (var node in FlattenJsonLdNodes(root))
|
||
{
|
||
if (!node.TryGetProperty("@type", out var typeEl)) continue;
|
||
|
||
var type = typeEl.ValueKind == JsonValueKind.String
|
||
? typeEl.GetString()
|
||
: typeEl.EnumerateArray().Select(e => e.GetString()).FirstOrDefault();
|
||
|
||
if (!string.Equals(type, "Product", StringComparison.OrdinalIgnoreCase)) continue;
|
||
|
||
if (node.TryGetProperty("name", out var n))
|
||
sb.AppendLine($"[Structured Data] Product name: {n.GetString()}");
|
||
if (node.TryGetProperty("sku", out var sku))
|
||
sb.AppendLine($"[Structured Data] SKU: {sku.GetString()}");
|
||
if (node.TryGetProperty("mpn", out var mpn))
|
||
sb.AppendLine($"[Structured Data] MPN: {mpn.GetString()}");
|
||
if (node.TryGetProperty("description", out var desc))
|
||
{
|
||
var d = desc.GetString() ?? "";
|
||
sb.AppendLine($"[Structured Data] Description: {d[..Math.Min(200, d.Length)]}");
|
||
}
|
||
|
||
if (node.TryGetProperty("offers", out var offers))
|
||
ExtractOfferPrice(offers, sb);
|
||
}
|
||
}
|
||
catch { /* ignore malformed JSON-LD */ }
|
||
}
|
||
|
||
return sb.Length > 0 ? sb.ToString() : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Recursively flattens a JSON-LD element into individual nodes for processing.
|
||
/// Handles a top-level array, a single object, and the WooCommerce/Yoast @graph
|
||
/// wrapper pattern where the root object has no @type but contains an "@graph" array.
|
||
/// </summary>
|
||
private static IEnumerable<JsonElement> FlattenJsonLdNodes(JsonElement el)
|
||
{
|
||
if (el.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var item in el.EnumerateArray())
|
||
foreach (var n in FlattenJsonLdNodes(item))
|
||
yield return n;
|
||
}
|
||
else if (el.ValueKind == JsonValueKind.Object)
|
||
{
|
||
// @graph wrapper: {"@graph": [...]} — recurse into the array
|
||
if (el.TryGetProperty("@graph", out var graph) && graph.ValueKind == JsonValueKind.Array)
|
||
{
|
||
foreach (var n in FlattenJsonLdNodes(graph))
|
||
yield return n;
|
||
}
|
||
else
|
||
{
|
||
yield return el;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dispatches JSON-LD offer extraction for both the single-object form
|
||
/// (<c>"offers": { ... }</c>) and the array form (<c>"offers": [ ... ]</c>)
|
||
/// that e-commerce platforms use interchangeably.
|
||
/// </summary>
|
||
private static void ExtractOfferPrice(JsonElement offers, StringBuilder sb)
|
||
{
|
||
if (offers.ValueKind == JsonValueKind.Object)
|
||
AppendOffer(offers, sb);
|
||
else if (offers.ValueKind == JsonValueKind.Array)
|
||
foreach (var offer in offers.EnumerateArray())
|
||
AppendOffer(offer, sb);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formats a single JSON-LD Offer object as a "[Structured Data] Price: ..." line
|
||
/// prepended to the page text. Includes currency, unit, and in-stock status when present.
|
||
/// Price is only emitted when a non-empty value exists; incomplete Offer objects (e.g.,
|
||
/// availability without a price) are silently skipped.
|
||
/// </summary>
|
||
private static void AppendOffer(JsonElement offer, StringBuilder sb)
|
||
{
|
||
// Accept "price" (Offer) or "lowPrice" (AggregateOffer — used by Shopify and others)
|
||
var price = offer.TryGetProperty("price", out var p) ? p.ToString() :
|
||
offer.TryGetProperty("lowPrice", out var lp) ? lp.ToString() : null;
|
||
var currency = offer.TryGetProperty("priceCurrency", out var c) ? c.GetString() : "USD";
|
||
var unit = offer.TryGetProperty("unitText", out var u) ? u.GetString() : null;
|
||
var avail = offer.TryGetProperty("availability", out var a) ? a.GetString() : null;
|
||
|
||
if (string.IsNullOrWhiteSpace(price)) return;
|
||
|
||
var priceStr = $"[Structured Data] Price: {currency} {price}";
|
||
if (!string.IsNullOrWhiteSpace(unit)) priceStr += $" per {unit}";
|
||
if (avail?.Contains("InStock", StringComparison.OrdinalIgnoreCase) == true)
|
||
priceStr += " (In Stock)";
|
||
sb.AppendLine(priceStr);
|
||
}
|
||
|
||
// ── Prompt builder ────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Assembles the Claude user-turn message from all available inputs.
|
||
/// The message is structured in three sections:
|
||
/// 1. The product identity block (what the user typed).
|
||
/// 2. Web search snippets (if Serper found any), or a fallback note instructing Claude
|
||
/// to rely on training knowledge.
|
||
/// 3. Full product page content (if a page was successfully fetched), preceded by an
|
||
/// explicit instruction to verify that the page content matches the requested product
|
||
/// before trusting it — this guards against TDS pages for a different product being
|
||
/// returned by the search.
|
||
/// </summary>
|
||
private static string BuildUserPrompt(
|
||
string? manufacturer, string? colorName, string? colorCode,
|
||
string? partNumber, List<string> snippets, string? fetchUrl, string? pageContent)
|
||
{
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("Product lookup request:");
|
||
if (!string.IsNullOrWhiteSpace(manufacturer)) sb.AppendLine($" Manufacturer: {manufacturer}");
|
||
if (!string.IsNullOrWhiteSpace(colorName)) sb.AppendLine($" Color Name: {colorName}");
|
||
if (!string.IsNullOrWhiteSpace(colorCode)) sb.AppendLine($" Color Code: {colorCode}");
|
||
if (!string.IsNullOrWhiteSpace(partNumber)) sb.AppendLine($" Part Number: {partNumber}");
|
||
|
||
if (snippets.Any())
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("Web search results:");
|
||
for (var i = 0; i < snippets.Count; i++)
|
||
sb.AppendLine($"[{i + 1}] {snippets[i]}");
|
||
}
|
||
else
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("No web search results available. Use your knowledge of this product/manufacturer to fill in what you can.");
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(pageContent))
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Product page fetched from: {fetchUrl}");
|
||
sb.AppendLine("IMPORTANT: Before using the page content below, verify it is for the product in the lookup request above.");
|
||
sb.AppendLine("If the page describes a different product, ignore all data from it and rely only on the search snippets and your training knowledge.");
|
||
sb.AppendLine("Lines starting with [Structured Data] are machine-readable product metadata extracted before HTML parsing — treat them as highly reliable.");
|
||
sb.AppendLine("Page content:");
|
||
sb.AppendLine(pageContent);
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(fetchUrl))
|
||
{
|
||
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
|
||
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
|
||
// in the path) to infer product identity rather than returning all-null fields.
|
||
sb.AppendLine();
|
||
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
|
||
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
|
||
}
|
||
|
||
return sb.ToString();
|
||
}
|
||
|
||
// ── JSON helpers ──────────────────────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Safely reads a string property from a <see cref="JsonElement"/>, returning <c>null</c>
|
||
/// for missing keys, non-string values, and empty strings. Prevents <c>null</c> being
|
||
/// stored in result fields when Claude returns JSON <c>null</c> for unknown fields.
|
||
/// </summary>
|
||
private static string? GetString(JsonElement el, string key)
|
||
{
|
||
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String)
|
||
return v.GetString() is { Length: > 0 } s ? s : null;
|
||
return null;
|
||
}
|
||
/// <summary>
|
||
/// Safely reads a numeric property as <see cref="decimal"/>, returning <c>null</c> when
|
||
/// the key is absent or the value is not a JSON number (e.g., when Claude returns <c>null</c>
|
||
/// for fields it could not determine).
|
||
/// </summary>
|
||
private static decimal? GetDecimal(JsonElement el, string key)
|
||
{
|
||
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Number)
|
||
return v.GetDecimal();
|
||
return null;
|
||
}
|
||
/// <summary>
|
||
/// Safely reads a numeric property as <see cref="int"/>, returning <c>null</c> when the
|
||
/// key is absent or the value is not a JSON number. Used for integer-only fields such
|
||
/// as <c>cureTimeMinutes</c>.
|
||
/// </summary>
|
||
private static int? GetInt(JsonElement el, string key)
|
||
{
|
||
if (el.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.Number)
|
||
return v.GetInt32();
|
||
return null;
|
||
}
|
||
/// <summary>
|
||
/// Safely reads a JSON boolean property as nullable <see cref="bool"/>, distinguishing
|
||
/// between <c>true</c>, <c>false</c>, and <c>null</c> (genuinely unknown). This is
|
||
/// important for <c>requiresClearCoat</c> where <c>null</c> means "unclear from data"
|
||
/// and should be displayed differently in the UI from an explicit <c>false</c>.
|
||
/// </summary>
|
||
private static bool? GetBool(JsonElement el, string key)
|
||
{
|
||
if (el.TryGetProperty(key, out var v))
|
||
{
|
||
if (v.ValueKind == JsonValueKind.True) return true;
|
||
if (v.ValueKind == JsonValueKind.False) return false;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static void ApplyPowderFallbacks(InventoryAiLookupResult result)
|
||
{
|
||
result.TransferEfficiency ??= DefaultTransferEfficiency;
|
||
|
||
if (!result.CoverageSqFtPerLb.HasValue && result.SpecificGravity is > 0)
|
||
{
|
||
var calculatedCoverage = TheoreticalCoverageConstant / (result.SpecificGravity.Value * DefaultCoverageThicknessMils);
|
||
result.CoverageSqFtPerLb = Math.Round(calculatedCoverage, 2, MidpointRounding.AwayFromZero);
|
||
}
|
||
}
|
||
}
|