DEV Community

Cover image for Turn Plain English into Safe, Fast C# Rules with Ollama and Expression Trees
David Au Yeung
David Au Yeung

Posted on

Turn Plain English into Safe, Fast C# Rules with Ollama and Expression Trees

Introduction

Every now and then a feature request shows up that sounds innocent but is secretly a trap:

"Can users just type their rules in plain English and we run them?"

The lazy version of this is terrifying: ask an LLM to write C# code and then execute it. That's a one-way ticket to code injection, impossible-to-validate logic, and a security review that never ends.

So in this experiment we do the opposite. We keep the AI on a short leash and let it do the one thing it's genuinely great at - translating language - while our engine keeps full control of execution.

The flow is:

Natural language → AI → structured rule JSON → validate → expression tree → compiled Func<T, bool> → run

The AI never produces C#. It only produces data (JSON). Our code turns that validated data into a real, compiled delegate using one of my favorite tools in all of .NET: expression trees.

We will use:

  • .NET 8
  • OllamaSharp
  • http://localhost:11434
  • nemotron-3-super:cloud (any decent instruct model works)

And yes - there's a whole section near the end dedicated to this little line, because I think it's a genuinely beautiful piece of the framework:

return Expression.Lambda<Func<T, bool>>(body, parameter);
Enter fullscreen mode Exit fullscreen mode

What We Are Building

User text: "wage must be at least 40 and hours must not exceed 60"
  -> AiRuleParser  (reuses your Ollama chat service)
  -> Ollama (nemotron-3-super:cloud)
  -> Rule JSON (strict schema)
  -> RuleNodeJsonConverter (validate + deserialize)
  -> RuleExpressionBuilder (whitelist fields + operators)
  -> Expression<Func<Employee, bool>>
  -> .Compile()
  -> Func<Employee, bool>
  -> run against your data
Enter fullscreen mode Exit fullscreen mode

The key idea, stated bluntly:

  • ⚠️ AI never generates C# code
  • ✅ AI only generates structured JSON
  • ✅ Your system converts JSON → compiled code, safely

The Golden Rule: AI Writes Data, Not Code

If you let the model emit C#:

  • ❌ Code injection
  • ❌ Impossible to validate
  • ❌ Impossible to restrict

If you let the model emit JSON that conforms to a schema you own:

  • ✅ Safe (you choose which operators/fields exist)
  • ✅ Validatable (bad JSON is rejected)
  • ✅ Deterministic (same shape every time)
  • ✅ You stay in control

Everything below is just a faithful implementation of that one principle.

Prerequisites

  • Visual Studio or VS Code
  • .NET 8 SDK
  • Ollama running locally
  • A model pulled into your Ollama environment

Run Ollama with the model used in this demo:

ollama run nemotron-3-super:cloud
Enter fullscreen mode Exit fullscreen mode

If your Ollama service is already running at http://localhost:11434, you are good.

Step 1: Create a New Console App

dotnet new console -n AiRuleEngineDemo
cd AiRuleEngineDemo
dotnet add package OllamaSharp
Enter fullscreen mode Exit fullscreen mode

Step 2: Project File (Reference)

Your .csproj should include at least:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="OllamaSharp" Version="5.4.25" />
  </ItemGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

No System.Text.Json or System.Linq.Expressions package needed - both ship in the BCL.

Step 3: Define the Rule Model (the AST)

This is the only shape the AI is ever allowed to produce. Three node types:

  • a comparison (Wage >= 40)
  • an and group
  • an or group
using System.Text.Json.Serialization;

namespace AiRuleEngine;

// Structured rule model (an AST). The AI only ever produces one of these shapes
// as JSON - it never produces executable C#. The engine is what turns this
// validated data into a compiled delegate.
[JsonConverter(typeof(RuleNodeJsonConverter))]
public abstract class RuleNode
{
    // Discriminator: "comparison", "and", or "or".
    public string Type { get; set; } = string.Empty;
}

public sealed class ComparisonRule : RuleNode
{
    public string Field { get; set; } = string.Empty;
    public string Operator { get; set; } = string.Empty;
    public decimal Value { get; set; }
}

public sealed class LogicalRule : RuleNode
{
    public List<RuleNode> Rules { get; set; } = new();
}
Enter fullscreen mode Exit fullscreen mode

A nested rule like "wage ≥ 40 AND hours ≤ 60" serializes to:

{
  "type": "and",
  "rules": [
    { "type": "comparison", "field": "Wage",  "operator": ">=", "value": 40 },
    { "type": "comparison", "field": "Hours", "operator": "<=", "value": 60 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Teach JSON How to Be Polymorphic

There's one wrinkle: both "and" and "or" map to the same LogicalRule type, so the built-in [JsonDerivedType] attribute won't cut it (it needs a unique discriminator per type). A small custom converter handles it - and conveniently gives us a home for the "strip Markdown fences" helper, because local models love wrapping JSON in

```json

.

using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;

namespace AiRuleEngine;

public sealed class RuleNodeJsonConverter : JsonConverter<RuleNode>
{
    public override RuleNode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        using var document = JsonDocument.ParseValue(ref reader);
        return ReadElement(document.RootElement, options);
    }

    private static RuleNode ReadElement(JsonElement element, JsonSerializerOptions options)
    {
        if (element.ValueKind != JsonValueKind.Object)
        {
            throw new JsonException("Each rule node must be a JSON object.");
        }

        if (!element.TryGetProperty("type", out var typeProperty) ||
            typeProperty.ValueKind != JsonValueKind.String)
        {
            throw new JsonException("Rule node is missing a required string 'type' property.");
        }

        var type = typeProperty.GetString()!.Trim().ToLowerInvariant();

        return type switch
        {
            "comparison" => ReadComparison(element),
            "and" or "or" => ReadLogical(element, type, options),
            _ => throw new JsonException($"Unsupported rule type '{type}'. Allowed: comparison, and, or.")
        };
    }

    private static ComparisonRule ReadComparison(JsonElement element)
    {
        if (!element.TryGetProperty("field", out var field) || field.ValueKind != JsonValueKind.String)
        {
            throw new JsonException("Comparison rule requires a string 'field'.");
        }

        if (!element.TryGetProperty("operator", out var op) || op.ValueKind != JsonValueKind.String)
        {
            throw new JsonException("Comparison rule requires a string 'operator'.");
        }

        if (!element.TryGetProperty("value", out var value))
        {
            throw new JsonException("Comparison rule requires a 'value'.");
        }

        return new ComparisonRule
        {
            Type = "comparison",
            Field = field.GetString()!,
            Operator = op.GetString()!,
            Value = ReadDecimal(value)
        };
    }

    private static LogicalRule ReadLogical(JsonElement element, string type, JsonSerializerOptions options)
    {
        var rules = new List<RuleNode>();

        if (element.TryGetProperty("rules", out var rulesElement) &&
            rulesElement.ValueKind == JsonValueKind.Array)
        {
            foreach (var child in rulesElement.EnumerateArray())
            {
                rules.Add(ReadElement(child, options));
            }
        }

        if (rules.Count == 0)
        {
            throw new JsonException($"Logical rule '{type}' must contain at least one child rule.");
        }

        return new LogicalRule { Type = type, Rules = rules };
    }

    private static decimal ReadDecimal(JsonElement element)
    {
        return element.ValueKind switch
        {
            JsonValueKind.Number => element.GetDecimal(),
            JsonValueKind.String when decimal.TryParse(
                element.GetString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed) => parsed,
            _ => throw new JsonException("Comparison 'value' must be a number.")
        };
    }

    public override void Write(Utf8JsonWriter writer, RuleNode value, JsonSerializerOptions options)
    {
        switch (value)
        {
            case ComparisonRule comparison:
                writer.WriteStartObject();
                writer.WriteString("type", "comparison");
                writer.WriteString("field", comparison.Field);
                writer.WriteString("operator", comparison.Operator);
                writer.WriteNumber("value", comparison.Value);
                writer.WriteEndObject();
                break;

            case LogicalRule logical:
                writer.WriteStartObject();
                writer.WriteString("type", logical.Type);
                writer.WritePropertyName("rules");
                writer.WriteStartArray();
                foreach (var child in logical.Rules)
                {
                    Write(writer, child, options);
                }
                writer.WriteEndArray();
                writer.WriteEndObject();
                break;

            default:
                throw new JsonException($"Unknown rule node type '{value.GetType().Name}'.");
        }
    }

    // Local models often wrap JSON in prose or Markdown fences. Strip them and
    // extract the outermost JSON object so deserialization stays robust.
    public static string ExtractJsonObject(string text)
    {
        if (string.IsNullOrWhiteSpace(text))
        {
            throw new FormatException("Model returned an empty response.");
        }

        var cleaned = text.Trim();
        cleaned = Regex.Replace(cleaned, "^```
{% endraw %}
(?:json)?\\s*", string.Empty, RegexOptions.IgnoreCase);
        cleaned = Regex.Replace(cleaned, "\\s*
{% raw %}
```$", string.Empty, RegexOptions.IgnoreCase);

        var start = cleaned.IndexOf('{');
        var end = cleaned.LastIndexOf('}');

        if (start < 0 || end <= start)
        {
            throw new FormatException("No JSON object found in model response.");
        }

        return cleaned[start..(end + 1)];
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5: The Heart of It - Compile Rules with Expression Trees

This is the safety boundary and the performance trick. The AI handed us data; here we decide exactly which fields and operators are legal, and we assemble a real lambda from them.

using System.Linq.Expressions;
using System.Reflection;

namespace AiRuleEngine;

public static class RuleExpressionBuilder
{
    private static readonly string[] AllowedOperators = { ">", ">=", "<", "<=", "==", "!=" };

    // allowedFields (optional) restricts which properties of T a rule may touch.
    public static Expression<Func<T, bool>> BuildExpression<T>(
        RuleNode node,
        ISet<string>? allowedFields = null)
    {
        ArgumentNullException.ThrowIfNull(node);

        var parameter = Expression.Parameter(typeof(T), "x");
        var body = Build(node, parameter, allowedFields);
        return Expression.Lambda<Func<T, bool>>(body, parameter);
    }

    private static Expression Build(RuleNode node, ParameterExpression parameter, ISet<string>? allowedFields)
    {
        return node switch
        {
            ComparisonRule comparison => BuildComparison(comparison, parameter, allowedFields),
            LogicalRule logical => BuildLogical(logical, parameter, allowedFields),
            _ => throw new InvalidOperationException($"Unsupported rule node '{node.GetType().Name}'.")
        };
    }

    private static Expression BuildComparison(
        ComparisonRule comparison,
        ParameterExpression parameter,
        ISet<string>? allowedFields)
    {
        if (!AllowedOperators.Contains(comparison.Operator))
        {
            throw new InvalidOperationException(
                $"Operator '{comparison.Operator}' is not allowed. Allowed: {string.Join(", ", AllowedOperators)}.");
        }

        var property = ResolveProperty(parameter.Type, comparison.Field, allowedFields);
        var propertyAccess = Expression.Property(parameter, property);

        // Coerce the rule's decimal value into the property's actual type so the
        // comparison operands match (e.g. decimal -> int / double / decimal).
        var typedValue = Convert.ChangeType(comparison.Value, property.PropertyType);
        var constant = Expression.Constant(typedValue, property.PropertyType);

        return comparison.Operator switch
        {
            ">" => Expression.GreaterThan(propertyAccess, constant),
            ">=" => Expression.GreaterThanOrEqual(propertyAccess, constant),
            "<" => Expression.LessThan(propertyAccess, constant),
            "<=" => Expression.LessThanOrEqual(propertyAccess, constant),
            "==" => Expression.Equal(propertyAccess, constant),
            "!=" => Expression.NotEqual(propertyAccess, constant),
            _ => throw new InvalidOperationException($"Operator '{comparison.Operator}' is not supported.")
        };
    }

    private static Expression BuildLogical(
        LogicalRule logical,
        ParameterExpression parameter,
        ISet<string>? allowedFields)
    {
        if (logical.Rules is null || logical.Rules.Count == 0)
        {
            throw new InvalidOperationException("Logical rule must contain at least one child rule.");
        }

        var childExpressions = logical.Rules
            .Select(child => Build(child, parameter, allowedFields))
            .ToList();

        return logical.Type.ToLowerInvariant() switch
        {
            "and" => childExpressions.Aggregate(Expression.AndAlso),
            "or" => childExpressions.Aggregate(Expression.OrElse),
            _ => throw new InvalidOperationException($"Unsupported logical type '{logical.Type}'.")
        };
    }

    private static PropertyInfo ResolveProperty(Type type, string field, ISet<string>? allowedFields)
    {
        if (string.IsNullOrWhiteSpace(field))
        {
            throw new InvalidOperationException("Comparison rule is missing a field name.");
        }

        // Case-insensitive lookup tolerates the model's casing while still only
        // binding to a real, public property (no arbitrary reflection).
        var property = type.GetProperty(
            field, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
            ?? throw new InvalidOperationException($"Field '{field}' does not exist on type '{type.Name}'.");

        if (allowedFields is not null && !allowedFields.Contains(property.Name))
        {
            throw new InvalidOperationException($"Field '{property.Name}' is not in the allowed field list.");
        }

        return property;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three layers of safety live in this file:

  1. Operator whitelist - only the six operators we listed can ever exist.
  2. Field whitelist + existence check - a rule can only touch a real, allowed property. No reflection on arbitrary names.
  3. Type coercion - the model's number is converted to the property's real type before we build the comparison.

Now for the line I promised…

⭐ The One Line That Makes the Magic: Expression.Lambda<Func<T, bool>>(body, parameter)

Honestly? This is my favorite line in the whole project:

return Expression.Lambda<Func<T, bool>>(body, parameter);
Enter fullscreen mode Exit fullscreen mode

It looks tiny, but it's doing something kind of wonderful: we are building C# at runtime, with code.

Unpacking it

When you write a normal lambda by hand:

Func<Employee, bool> isMatch = x => x.Wage >= 40 && x.Hours <= 60;
Enter fullscreen mode Exit fullscreen mode

the compiler quietly splits x => ... into two pieces:

  • a parameter - the x
  • a body - the boolean expression x.Wage >= 40 && x.Hours <= 60

Expression trees let us build those exact two pieces ourselves, as data:

  • parameter is Expression.Parameter(typeof(T), "x") - that's the x.
  • body is the boolean tree we assembled from the rule (GreaterThanOrEqual, AndAlso, …).

Then this:

Expression.Lambda<Func<T, bool>>(body, parameter)
Enter fullscreen mode Exit fullscreen mode

stitches them into a complete lambda as an object - an Expression<Func<T, bool>>. It is not running yet. It's a description of x => body, sitting in memory, that you can inspect, log, cache, or store.

Print it and you literally see the code you assembled:

x => ((x.Wage >= 40) AndAlso (x.Hours <= 60))
Enter fullscreen mode Exit fullscreen mode

The payoff: .Compile()

var isMatch = expression.Compile();
Enter fullscreen mode Exit fullscreen mode

.Compile() turns that tree into a real delegate - actual IL, the same kind the compiler emits for a hand-written lambda. Compile it once, and from then on isMatch(employee) runs at basically hand-written speed. No reflection per call. No interpreter. No eval.

Why I keep calling it "a really good tool"

Because it gives you four things at the same time:

  • Safe 🔒 - you only ever build the operators you allow (GreaterThan, AndAlso, …). There's no path to "run arbitrary C#," unlike string-eval or Roslyn scripting. The AI literally cannot inject code, because it never emits code.
  • Fast ⚡ - compile once, run millions of times. Perfect for rule engines, filters, pricing, and policy checks.
  • Composable 🧩 - and/or are just Expression.AndAlso / Expression.OrElse over child expressions, so nesting falls right out of recursion.
  • Inspectable 🔎 - expression.ToString() is a free audit log and a fantastic debugging aid.

A mental model for the whole round trip:

RuleNode (data)
   -> Expression pieces (parameter + body)
   -> Expression.Lambda<Func<T,bool>>(...)   // code as data
   -> .Compile()                              // data becomes a live delegate
   -> isMatch(employee)                       // runs at native speed
Enter fullscreen mode Exit fullscreen mode

That journey - data → expression → compiled delegate - is the entire trick that makes "natural language to executable rule" both safe and fast.

Bonus: it composes with EF Core

Because Expression<Func<T, bool>> is exactly what IQueryable<T>.Where(...) accepts, the very same rule you built from a sentence can be pushed down into a database query and translated to SQL - instead of pulling rows into memory. One representation, two execution targets. That's the kind of leverage you don't get from a string interpreter.

Step 6: Reuse Your Ollama Service (Natural Language → JSON)

Here's where we "reuse the Ollama service." The parser depends on a tiny IChatbot abstraction - so if you already have an Ollama chat wrapper in your app, just implement IChatbot on it and pass it in. For a self-contained demo, here's a minimal OllamaSharp-backed adapter:

using OllamaSharp;
using System.Text;

namespace AiRuleEngine;

public interface IChatbot
{
    Task<string> AskQuestion(string userMessage);
}

// Minimal Ollama-backed chat service. Already have one? Implement IChatbot
// on your existing class and reuse it instead.
public sealed class OllamaChatbot : IChatbot
{
    private readonly OllamaApiClient _client;

    public OllamaChatbot(string endpoint, string model)
    {
        _client = new OllamaApiClient(new Uri(endpoint)) { SelectedModel = model };
    }

    public async Task<string> AskQuestion(string userMessage)
    {
        var builder = new StringBuilder();

        await foreach (var chunk in _client.GenerateAsync(userMessage))
        {
            if (!string.IsNullOrWhiteSpace(chunk?.Response))
            {
                builder.Append(chunk.Response);
            }
        }

        return builder.ToString();
    }
}
Enter fullscreen mode Exit fullscreen mode

And the parser itself - it builds the prompt, asks the model, extracts the JSON, and deserializes:

using System.Text;
using System.Text.Json;

namespace AiRuleEngine;

public sealed class AiRuleParser
{
    private static readonly JsonSerializerOptions SerializerOptions = new()
    {
        PropertyNameCaseInsensitive = true
    };

    private readonly IChatbot _chatbot;

    public AiRuleParser(IChatbot chatbot)
    {
        _chatbot = chatbot ?? throw new ArgumentNullException(nameof(chatbot));
    }

    // Convenience: derive the schema straight from the target type.
    public Task<RuleNode> ParseAsync<T>(string naturalLanguageRule)
        => ParseAsync(naturalLanguageRule, RuleSchema.FromType<T>());

    public async Task<RuleNode> ParseAsync(string naturalLanguageRule, RuleSchema? schema = null)
    {
        if (string.IsNullOrWhiteSpace(naturalLanguageRule))
        {
            throw new ArgumentException("Rule text must be provided.", nameof(naturalLanguageRule));
        }

        var prompt = BuildPrompt(naturalLanguageRule, schema);
        var rawResponse = await _chatbot.AskQuestion(prompt);
        var json = RuleNodeJsonConverter.ExtractJsonObject(rawResponse ?? string.Empty);

        return JsonSerializer.Deserialize<RuleNode>(json, SerializerOptions)
            ?? throw new FormatException("Model response did not deserialize into a rule.");
    }

    private static string BuildPrompt(string naturalLanguageRule, RuleSchema? schema)
    {
        var builder = new StringBuilder();
        builder.AppendLine("Convert the following business rule into strict JSON.");
        builder.AppendLine("Output JSON only. Do not include explanations or Markdown fences.");
        builder.AppendLine();
        builder.AppendLine("Supported node types: \"comparison\", \"and\", \"or\".");
        builder.AppendLine("Supported operators: \">\", \">=\", \"<\", \"<=\", \"==\", \"!=\".");
        builder.AppendLine("Every comparison \"value\" must be a number, so only compare numeric fields.");
        builder.AppendLine();
        builder.AppendLine("A comparison node looks like:");
        builder.AppendLine("{ \"type\": \"comparison\", \"field\": \"Wage\", \"operator\": \">=\", \"value\": 40 }");
        builder.AppendLine();
        builder.AppendLine("An and/or node groups child rules:");
        builder.AppendLine("{ \"type\": \"and\", \"rules\": [ { ...comparison... }, { ...comparison... } ] }");

        // Feed the schema so the model uses real fields and correct casing.
        if (schema is not null && schema.Fields.Count > 0)
        {
            builder.AppendLine();
            builder.AppendLine(schema.ToPromptDescription());
            builder.AppendLine();
            builder.AppendLine("Only use the fields listed above and match their casing exactly.");
        }

        builder.AppendLine();
        builder.AppendLine("Rule:");
        builder.Append(naturalLanguageRule);

        return builder.ToString();
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Give the Model a Schema (So It Stops Guessing)

Passing just field names is the bare minimum. The model does noticeably better when it also knows each field's type (and ideally a short description). Even better: derive the schema and the whitelist from the target type via reflection, so they can never drift apart.

using System.ComponentModel;
using System.Reflection;
using System.Text;

namespace AiRuleEngine;

public sealed class RuleField
{
    public RuleField(string name, string typeName, string? description = null)
    {
        Name = name;
        TypeName = typeName;
        Description = description;
    }

    public string Name { get; }
    public string TypeName { get; }       // friendly: "number", "text", "date", "boolean"
    public string? Description { get; }
}

public sealed class RuleSchema
{
    private RuleSchema(IReadOnlyList<RuleField> fields)
    {
        Fields = fields;
        FieldNames = new HashSet<string>(fields.Select(f => f.Name), StringComparer.OrdinalIgnoreCase);
    }

    public IReadOnlyList<RuleField> Fields { get; }

    // Pass directly to RuleExpressionBuilder.BuildExpression as the whitelist.
    public ISet<string> FieldNames { get; }

    public static RuleSchema FromType<T>() => FromType(typeof(T));

    public static RuleSchema FromType(Type type)
    {
        var fields = type
            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
            .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
            .Select(p => new RuleField(
                p.Name,
                DescribeType(p.PropertyType),
                p.GetCustomAttribute<DescriptionAttribute>()?.Description))
            .ToList();

        return new RuleSchema(fields);
    }

    public string ToPromptDescription()
    {
        var builder = new StringBuilder();
        builder.AppendLine("Available fields (use the exact names and respect the types):");

        foreach (var field in Fields)
        {
            builder.Append("- ").Append(field.Name).Append(" (").Append(field.TypeName).Append(')');
            if (!string.IsNullOrWhiteSpace(field.Description))
            {
                builder.Append(" - ").Append(field.Description);
            }
            builder.AppendLine();
        }

        return builder.ToString().TrimEnd();
    }

    private static string DescribeType(Type type)
    {
        var underlying = Nullable.GetUnderlyingType(type) ?? type;

        if (underlying.IsEnum) return $"text (one of: {string.Join(", ", Enum.GetNames(underlying))})";
        if (underlying == typeof(bool)) return "boolean";
        if (underlying == typeof(DateTime) || underlying == typeof(DateTimeOffset) || underlying == typeof(DateOnly)) return "date";
        if (underlying == typeof(string) || underlying == typeof(char)) return "text";

        if (underlying == typeof(byte) || underlying == typeof(sbyte) ||
            underlying == typeof(short) || underlying == typeof(ushort) ||
            underlying == typeof(int) || underlying == typeof(uint) ||
            underlying == typeof(long) || underlying == typeof(ulong) ||
            underlying == typeof(float) || underlying == typeof(double) ||
            underlying == typeof(decimal)) return "number";

        return underlying.Name.ToLowerInvariant();
    }
}
Enter fullscreen mode Exit fullscreen mode

With this, the prompt grows from a flat list of names into something the model can actually reason about:

Available fields (use the exact names and respect the types):
- Name (text)
- Wage (number) - Hourly pay rate in dollars
- Hours (number) - Hours worked per week
- Age (number) - Age in years
Enter fullscreen mode Exit fullscreen mode

Step 8: Wire Up the Demo in Program.cs

using AiRuleEngine;
using System.ComponentModel;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;

Console.OutputEncoding = Encoding.UTF8;

const string endpoint = "http://localhost:11434";
const string model = "nemotron-3-super:cloud";

// Reuse YOUR Ollama chat service here. Anything that implements IChatbot works.
IChatbot ollama = new OllamaChatbot(endpoint, model);
var parser = new AiRuleParser(ollama);

// Single source of truth: schema + whitelist both come from the type.
var schema = RuleSchema.FromType<Employee>();

Console.WriteLine("Describe a rule in plain English.");
Console.WriteLine("Example: wage must be at least 40 and hours must not exceed 60");
Console.Write("> ");

var input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input))
{
    input = "wage must be at least 40 and hours must not exceed 60";
    Console.WriteLine($"(no input - using sample rule: \"{input}\")");
}

var ruleNode = await parser.ParseAsync(input, schema);

// Audit log: show the structured JSON the AI produced.
// UnsafeRelaxedJsonEscaping keeps operators readable (>= instead of \u003E=).
var auditJson = JsonSerializer.Serialize(ruleNode, new JsonSerializerOptions
{
    WriteIndented = true,
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
});
Console.WriteLine();
Console.WriteLine("AI-generated rule JSON:");
Console.WriteLine(auditJson);

// The star of the show: build the expression, then compile it.
var expression = RuleExpressionBuilder.BuildExpression<Employee>(ruleNode, schema.FieldNames);
Console.WriteLine();
Console.WriteLine($"Compiled expression: {expression}");

var isMatch = expression.Compile();

var employees = new[]
{
    new Employee { Name = "Alice", Wage = 45m, Hours = 55m, Age = 31 },
    new Employee { Name = "Bob",   Wage = 35m, Hours = 50m, Age = 27 },
    new Employee { Name = "Carol", Wage = 50m, Hours = 65m, Age = 44 }
};

Console.WriteLine();
Console.WriteLine("Evaluation:");
foreach (var e in employees)
{
    Console.WriteLine($"  {e.Name,-6} Wage={e.Wage,-5} Hours={e.Hours,-5} Age={e.Age,-3} => {isMatch(e)}");
}

public sealed class Employee
{
    public string Name { get; set; } = string.Empty;

    [Description("Hourly pay rate in dollars")]
    public decimal Wage { get; set; }

    [Description("Hours worked per week")]
    public decimal Hours { get; set; }

    [Description("Age in years")]
    public int Age { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Run It

dotnet run
Enter fullscreen mode Exit fullscreen mode

Type a rule (or press Enter to use the sample). A typical session looks like this:

Notice the middle line - that's our expression tree printing itself back out. The AI gave us data; we turned it into running code.

Why Expression Trees Beat the Alternatives

Approach Safe? Fast? Notes
Ask the LLM for C#, then run it - Code injection; impossible to validate; never ship this
Roslyn scripting (CSharpScript) Compiles arbitrary C#; heavy; same injection surface
String rule interpreter You reinvent a parser; re-parses every evaluation
Reflection per evaluation Safe but slow; reflection cost on every call
Expression tree + .Compile() Whitelisted operations, compiled once, native speed, inspectable

Why This Is a Fun (and Safe) Experiment

  • You let users write rules in plain English without handing them a keyboard into your runtime.
  • The AI stays in its lane: translation only, never execution.
  • Every generated rule is auditable - store the original text, the AI JSON, and the compiled ToString().
  • The same Expression<Func<T, bool>> works in-memory and against IQueryable/EF Core.
  • It's a tiny, satisfying demo of "code as data" that you can extend in a dozen directions.

Notes and Limitations

  • This demo compares numbers only (ComparisonRule.Value is decimal). To support text/date/boolean, widen the value model and add the matching Expression operators.
  • Small local models occasionally wander off-schema. The fence-stripping + JSON extraction helper covers the common cases; for production, add JSON-schema validation and a validate → repair loop that feeds the validation error back to the model for one retry.
  • Cache compiled delegates keyed by the rule JSON if you evaluate the same rule a lot - compile once, reuse forever.
  • Keep the field whitelist tight. It's your last line of defense even though the AI never emits code.

Closing

The whole idea fits in one sentence:

Let the AI translate, and let expression trees execute.

Start from a sentence, get back strict JSON, validate it, and compile it into a real Func<T, bool> with this lovely little line:

return Expression.Lambda<Func<T, bool>>(body, parameter);
Enter fullscreen mode Exit fullscreen mode

Safe, fast, composable, inspectable. That's why I think it's a really good tool too.

Love C#!

Top comments (0)