Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex fabricated items (roof curbs, enclosures, welded frames). Templates support two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt (formula yields sq ft fed into the standard coating engine). Includes: - CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo - IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService overloads and all existingItemsData JSON projections + pageMeta blocks - ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula generator (natural language + optional diagram image) and NCalc2 evaluator - CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete, UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi - Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js - item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields - Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details - Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action, HelpKnowledgeBase entry; 225/225 unit tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="GenerateFormulaFromAiResponse"/>.
|
||||
/// </summary>
|
||||
public class CustomFormulaAiService : ICustomFormulaAiService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<CustomFormulaAiService> _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. NCalc supports standard math operators (+, -, *, /, %, Pow()),
|
||||
comparison operators, and the Abs(), Round(), Max(), Min() built-in functions.
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
|
||||
- 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<CustomFormulaAiService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GenerateFormulaFromAiResponse> 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<ContentBase>();
|
||||
|
||||
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<Message>
|
||||
{
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Formula))
|
||||
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
|
||||
|
||||
try
|
||||
{
|
||||
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||
request.VariablesJson ?? "{}") ?? new();
|
||||
|
||||
var expr = new Expression(request.Formula);
|
||||
foreach (var kv in variables)
|
||||
{
|
||||
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||
? (object)kv.Value.GetDecimal()
|
||||
: (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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user