diff --git a/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs b/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs index 49a4db1..2a55c59 100644 --- a/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs +++ b/src/PowderCoating.Application/Interfaces/ICustomFormulaAiService.cs @@ -19,4 +19,12 @@ public interface ICustomFormulaAiService /// Safe server-side only — no user-controlled code execution. /// EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request); + + /// + /// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then + /// attempts a parse-only evaluation to catch syntax errors before the formula is saved. + /// Returns the normalized formula string and a null error on success, or the original + /// formula and an error message on failure. + /// + (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula); } diff --git a/src/PowderCoating.Infrastructure/Services/CustomFormulaAiService.cs b/src/PowderCoating.Infrastructure/Services/CustomFormulaAiService.cs index 679ad18..617b5d4 100644 --- a/src/PowderCoating.Infrastructure/Services/CustomFormulaAiService.cs +++ b/src/PowderCoating.Infrastructure/Services/CustomFormulaAiService.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using NCalc2; using Anthropic.SDK; using Anthropic.SDK.Messaging; @@ -24,8 +25,22 @@ public class CustomFormulaAiService : ICustomFormulaAiService 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. NCalc supports standard math operators (+, -, *, /, %, Pow()), -comparison operators, and the Abs(), Round(), Max(), Min() built-in functions. +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. @@ -172,6 +187,35 @@ Rules: } } + // 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) { @@ -183,7 +227,7 @@ Rules: var variables = JsonSerializer.Deserialize>( request.VariablesJson ?? "{}") ?? new(); - var expr = new Expression(request.Formula); + var expr = new Expression(NormalizeFormula(request.Formula)); foreach (var kv in variables) { expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index 8e36e7f..f791066 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -3054,6 +3054,10 @@ public class CompanySettingsController : Controller var fieldError = ValidateTemplateFields(dto.FieldsJson); if (fieldError != null) return Json(new { success = false, message = fieldError }); + var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); + if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" }); + dto.Formula = normalizedFormula; + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = _mapper.Map(dto); entity.CompanyId = companyId; @@ -3076,6 +3080,10 @@ public class CompanySettingsController : Controller var fieldError = ValidateTemplateFields(dto.FieldsJson); if (fieldError != null) return Json(new { success = false, message = fieldError }); + var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula); + if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" }); + dto.Formula = normalizedFormula; + var companyId = _tenantContext.GetCurrentCompanyId()!.Value; var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id); if (entity == null || entity.CompanyId != companyId) diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml index b115815..ee41880 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml @@ -2343,6 +2343,40 @@ Variables (click to insert): +
+ + Formula reference + +
+
+
+
+ Operators + +  -  *  /  %
+ <  >  <=  >=  ==  !=
+ &&  ||  ! + Built-in variables (auto-injected) + rate — template’s default rate
+ standard_labor_rate
+ markup_pct
+ additional_coat_labor_pct +
+
+ Functions (must be lowercase) + if(cond, a, b) — conditional
+ abs(x)
+ round(x, digits)
+ max(a, b) / min(a, b)
+ pow(base, exp)
+ sqrt(x) + Example + if(qty > 10, qty * rate * 0.9, qty * rate) + 10% discount over 10 units +
+
+
+
+