using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using NCalc2; using Anthropic.SDK; using Anthropic.SDK.Messaging; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using PowderCoating.Application.DTOs.Company; using PowderCoating.Application.Interfaces; namespace PowderCoating.Infrastructure.Services; /// /// Generates NCalc pricing formula templates from natural-language descriptions using /// Claude Sonnet. Accepts an optional diagram image so the model can see the physical /// shape being estimated. The model returns a structured JSON object containing the /// field list, NCalc expression, output mode, and verification inputs; the service /// parses and returns it as a . /// public class CustomFormulaAiService : ICustomFormulaAiService { private readonly IConfiguration _config; private readonly ILogger _logger; private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business. Your job is to generate NCalc expressions that calculate either a fixed price or a surface area from user-supplied field values. CRITICAL: NCalc function names are CASE-SENSITIVE and must be ALL LOWERCASE. Supported built-in functions (always write these exactly as shown): if(condition, trueValue, falseValue) — conditional expression abs(x) — absolute value round(x, digits) — round to N decimal places max(a, b) — larger of two values min(a, b) — smaller of two values pow(base, exponent) — exponentiation sqrt(x) — square root Standard operators: + - * / % Comparison operators: < > <= >= == != Boolean operators: && || ! Do NOT use: IF, Abs, Round, Max, Min, Pow, Sqrt (uppercase versions) — NCalc will reject them. The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure', 'Tubular frame') and you must produce a pricing formula template. Respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation: { ""name"": ""string — short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"", ""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"", ""fields"": [ { ""name"": ""snake_case_variable_name"", ""label"": ""Human-readable label"", ""unit"": ""in / ft / mm / cm / qty / lbs — or empty string"", ""defaultValue"": number } ], ""formula"": ""NCalc expression using field name variables and optionally 'rate'"", ""defaultRate"": number or null, ""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' — null if no rate"", ""reasoning"": ""1-2 sentences explaining how the formula was derived"", ""verificationInputs"": { ""variable_name"": number }, ""verificationResult"": number } Built-in shop-rate variables (injected automatically at eval time — do NOT redeclare them as fields): standard_labor_rate — shop billing rate in $/hr (e.g. hours * standard_labor_rate) additional_coat_labor_pct — extra-coat labor surcharge 0–100 (e.g. cost * (1 + additional_coat_labor_pct/100)) markup_pct — general markup percentage 0–100 (e.g. cost * (1 + markup_pct/100)) Rules: - Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft) - Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest - Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in - Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens) - Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array — they are injected automatically - verificationInputs and verificationResult must use the exact field names and formula you generated - Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed - For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying "; public CustomFormulaAiService(IConfiguration config, ILogger logger) { _config = config; _logger = logger; } /// public async Task GenerateFormulaAsync( GenerateFormulaFromAiRequest request, byte[]? imageBytes = null, string? imageContentType = null) { var apiKey = _config["AI:Anthropic:ApiKey"]; if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-")) { return new GenerateFormulaFromAiResponse { Success = false, Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json." }; } try { var client = new AnthropicClient(apiKey); var userContent = new List(); if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType)) { userContent.Add(new ImageContent { Source = new ImageSource { MediaType = imageContentType, Data = Convert.ToBase64String(imageBytes) } }); } userContent.Add(new TextContent { Text = request.Description }); var messages = new List { new() { Role = RoleType.User, Content = userContent } }; var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters { Model = "claude-sonnet-4-6", MaxTokens = 1024, SystemMessage = SystemPrompt, Messages = messages }); var rawJson = response.Message.ToString().Trim(); // Strip markdown code fences if the model adds them if (rawJson.StartsWith("```")) { var start = rawJson.IndexOf('\n') + 1; var end = rawJson.LastIndexOf("```"); if (end > start) rawJson = rawJson[start..end].Trim(); } using var doc = JsonDocument.Parse(rawJson); var root = doc.RootElement; var fieldsJson = root.TryGetProperty("fields", out var fieldsEl) ? fieldsEl.GetRawText() : "[]"; decimal? defaultRate = null; if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number) defaultRate = rateEl.GetDecimal(); decimal? verificationResult = null; if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number) verificationResult = vrEl.GetDecimal(); string? verificationInputs = null; if (root.TryGetProperty("verificationInputs", out var viEl)) verificationInputs = viEl.GetRawText(); return new GenerateFormulaFromAiResponse { Success = true, Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null, OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate", FieldsJson = fieldsJson, Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null, DefaultRate = defaultRate, RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null, Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null, VerificationResult = verificationResult, VerificationInputs = verificationInputs }; } catch (Exception ex) { _logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed"); return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message }; } } // Lowercase NCalc built-in names before evaluation so that user-typed or AI-generated // uppercase variants (IF, Abs, POW, etc.) don't produce "Function not found" errors. private static readonly Regex _ncalcFuncRegex = new( @"\b(if|abs|round|max|min|pow|sqrt|ceiling|floor|truncate|sign|log|exp)\b(?=\s*\()", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static string NormalizeFormula(string formula) => _ncalcFuncRegex.Replace(formula, m => m.Value.ToLowerInvariant()); /// public (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula) { if (string.IsNullOrWhiteSpace(formula)) return (formula, "Formula cannot be empty."); var normalized = NormalizeFormula(formula); try { var expr = new Expression(normalized); if (expr.HasErrors()) return (formula, expr.Error); return (normalized, null); } catch (Exception ex) { return (formula, ex.Message); } } /// public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request) { if (string.IsNullOrWhiteSpace(request?.Formula)) return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." }; try { var variables = JsonSerializer.Deserialize>( request.VariablesJson ?? "{}") ?? new(); var expr = new Expression(NormalizeFormula(request.Formula)); foreach (var kv in variables) { expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number ? (object)kv.Value.GetDouble() : (object)(kv.Value.GetString() ?? ""); } var result = expr.Evaluate(); var decResult = Convert.ToDecimal(result); return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) }; } catch (Exception ex) { return new EvaluateFormulaResponse { Success = false, Error = ex.Message }; } } }