Fix NCalc case sensitivity and add formula validation
- Normalize IF/Abs/Pow/etc. to lowercase before evaluation so AI-generated or manually typed uppercase function names no longer cause "Function not found" errors - Add NormalizeAndValidate() which normalizes then does a parse-only check on save — invalid formulas are rejected with a clear error before storing - Update AI system prompt to list all functions in lowercase and explicitly call out case-sensitivity; add if() to the supported function list - Add collapsible NCalc quick-reference panel in the formula editor showing all operators, functions (lowercase), built-in variables, and an example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,4 +19,12 @@ public interface ICustomFormulaAiService
|
|||||||
/// Safe server-side only — no user-controlled code execution.
|
/// Safe server-side only — no user-controlled code execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using NCalc2;
|
using NCalc2;
|
||||||
using Anthropic.SDK;
|
using Anthropic.SDK;
|
||||||
using Anthropic.SDK.Messaging;
|
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.
|
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
|
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()),
|
from user-supplied field values.
|
||||||
comparison operators, and the Abs(), Round(), Max(), Min() built-in functions.
|
|
||||||
|
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',
|
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
|
||||||
'Tubular frame') and you must produce a pricing formula template.
|
'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());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||||
{
|
{
|
||||||
@@ -183,7 +227,7 @@ Rules:
|
|||||||
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||||
request.VariablesJson ?? "{}") ?? new();
|
request.VariablesJson ?? "{}") ?? new();
|
||||||
|
|
||||||
var expr = new Expression(request.Formula);
|
var expr = new Expression(NormalizeFormula(request.Formula));
|
||||||
foreach (var kv in variables)
|
foreach (var kv in variables)
|
||||||
{
|
{
|
||||||
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||||
|
|||||||
@@ -3054,6 +3054,10 @@ public class CompanySettingsController : Controller
|
|||||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
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 companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||||
entity.CompanyId = companyId;
|
entity.CompanyId = companyId;
|
||||||
@@ -3076,6 +3080,10 @@ public class CompanySettingsController : Controller
|
|||||||
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
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 companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
|
||||||
if (entity == null || entity.CompanyId != companyId)
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
|||||||
@@ -2343,6 +2343,40 @@
|
|||||||
<span class="me-1">Variables (click to insert):</span>
|
<span class="me-1">Variables (click to insert):</span>
|
||||||
<span id="cfVariablePills"></span>
|
<span id="cfVariablePills"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a class="small text-decoration-none" data-bs-toggle="collapse" href="#cfFormulaRef" role="button">
|
||||||
|
<i class="bi bi-question-circle me-1"></i>Formula reference
|
||||||
|
</a>
|
||||||
|
<div class="collapse" id="cfFormulaRef">
|
||||||
|
<div class="card card-body py-2 px-3 mt-1 small border-secondary-subtle" style="font-size:.8rem">
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong class="d-block mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Operators</strong>
|
||||||
|
<code>+ - * / %</code><br>
|
||||||
|
<code>< > <= >= == !=</code><br>
|
||||||
|
<code>&& || !</code>
|
||||||
|
<strong class="d-block mt-2 mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Built-in variables (auto-injected)</strong>
|
||||||
|
<code>rate</code> — template’s default rate<br>
|
||||||
|
<code>standard_labor_rate</code><br>
|
||||||
|
<code>markup_pct</code><br>
|
||||||
|
<code>additional_coat_labor_pct</code>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<strong class="d-block mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Functions (must be lowercase)</strong>
|
||||||
|
<code>if(cond, a, b)</code> — conditional<br>
|
||||||
|
<code>abs(x)</code><br>
|
||||||
|
<code>round(x, digits)</code><br>
|
||||||
|
<code>max(a, b)</code> / <code>min(a, b)</code><br>
|
||||||
|
<code>pow(base, exp)</code><br>
|
||||||
|
<code>sqrt(x)</code>
|
||||||
|
<strong class="d-block mt-2 mb-1 text-muted text-uppercase" style="font-size:.65rem;letter-spacing:.05em">Example</strong>
|
||||||
|
<code class="d-block text-break">if(qty > 10, qty * rate * 0.9, qty * rate)</code>
|
||||||
|
<span class="text-muted">10% discount over 10 units</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Notes</label>
|
<label class="form-label">Notes</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user