Learnixo
Back to blog
Backend Systemsadvanced

OrderFlow: Adding AI Features — Semantic Search, Summaries, and a Support Chatbot

Add AI to the OrderFlow API: pgvector semantic product search, AI-generated order summaries with streaming, and a customer support chatbot with conversation history and tool calling.

Asma Hafeez KhanMay 25, 20268 min read
.NETC#AIpgvectorOrderFlowstreamingRAGchatbot
Share:𝕏

OrderFlow: Adding AI Features — Semantic Search, Summaries, and a Support Chatbot

This is part 7 of the OrderFlow series. The full backend is built and tested. Now we add three AI-powered features that transform OrderFlow from a standard CRUD API into a genuinely intelligent system.

What we're adding:

  1. Semantic product search (pgvector)
  2. AI-generated order summaries with streaming
  3. Customer support chatbot with tools and conversation history

Step 1: Setup AI Services

C#
// Program.cs
builder.Services.AddChatClient(services =>
    new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!)
        .AsChatClient("gpt-4o"))
    .UseFunctionInvocation()
    .UseLogging()
    .UseDistributedCache();

builder.Services.AddEmbeddingGenerator<string, Embedding<float>>(services =>
    new OpenAIClient(builder.Configuration["OpenAI:ApiKey"]!)
        .AsEmbeddingGenerator("text-embedding-3-small"));
XML
<!-- Enable pgvector -->
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.*" />

Feature 1: Semantic Product Search

Update the Product Entity

C#
// src/OrderFlow.Core/Entities/Product.cs
public class Product
{
    public int      Id          { get; set; }
    public string   Name        { get; set; } = "";
    public string   Description { get; set; } = "";
    public string   Category    { get; set; } = "";
    public decimal  Price       { get; set; }
    public int      StockLevel  { get; set; }
    public bool     IsActive    { get; set; }

    // AI embedding — null until generated
    public Vector?  Embedding   { get; set; }
}
C#
// DbContext — add vector column + HNSW index
model.HasPostgresExtension("vector");

model.Entity<Product>(e =>
{
    e.Property(p => p.Embedding).HasColumnType("vector(1536)");
    e.HasIndex(p => p.Embedding)
     .HasMethod("hnsw")
     .HasOperators("vector_cosine_ops");
});

Embedding Generation

C#
// src/OrderFlow.Application/Products/Commands/GenerateProductEmbeddingsCommand.cs
public class GenerateProductEmbeddingsCommandHandler(
    OrderFlowDbContext db,
    IEmbeddingGenerator<string, Embedding<float>> embedder)
    : IRequestHandler<GenerateProductEmbeddingsCommand, int>
{
    public async Task<int> Handle(
        GenerateProductEmbeddingsCommand cmd,
        CancellationToken ct)
    {
        var products = await db.Products
            .Where(p => p.IsActive && p.Embedding == null)
            .Take(100)   // batch
            .ToListAsync(ct);

        if (products.Count == 0) return 0;

        // Build rich text representations for embedding
        var texts = products.Select(p =>
            $"{p.Name}. {p.Description}. Category: {p.Category}. Price: {p.Price:C}.").ToList();

        var results = await embedder.GenerateAsync(texts, cancellationToken: ct);

        for (int i = 0; i < products.Count; i++)
            products[i].Embedding = new Vector(results[i].Vector.ToArray());

        await db.SaveChangesAsync(ct);
        return products.Count;
    }
}

// Background job to keep embeddings current
public class EmbeddingGenerationJob(ISender mediator, ILogger<EmbeddingGenerationJob> logger)
    : IHostedService
{
    public async Task StartAsync(CancellationToken ct)
    {
        // Run once at startup; Quartz/Hangfire would handle scheduling in production
        var count = await mediator.Send(new GenerateProductEmbeddingsCommand(), ct);
        logger.LogInformation("Generated {Count} product embeddings on startup", count);
    }

    public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

Semantic Search Query

C#
public class SemanticProductSearchQueryHandler(
    OrderFlowDbContext db,
    IEmbeddingGenerator<string, Embedding<float>> embedder)
    : IRequestHandler<SemanticProductSearchQuery, List<ProductSearchResult>>
{
    public async Task<List<ProductSearchResult>> Handle(
        SemanticProductSearchQuery query,
        CancellationToken ct)
    {
        // Embed the user's search query
        var result      = await embedder.GenerateAsync([query.SearchText], cancellationToken: ct);
        var queryVector = new Vector(result[0].Vector.ToArray());

        // Vector similarity search
        var products = await db.Products
            .Where(p => p.IsActive && p.Embedding != null)
            .OrderBy(p => p.Embedding!.CosineDistance(queryVector))
            .Take(query.TopK)
            .Select(p => new ProductSearchResult(
                p.Id,
                p.Name,
                p.Category,
                p.Price,
                p.StockLevel,
                // Similarity score 0-1 (1 = identical)
                1 - p.Embedding!.CosineDistance(queryVector)))
            .ToListAsync(ct);

        // Filter out results below relevance threshold
        return products.Where(p => p.Similarity > 0.7).ToList();
    }
}

public record SemanticProductSearchQuery(string SearchText, int TopK = 10)
    : IRequest<List<ProductSearchResult>>;

public record ProductSearchResult(
    int    Id,
    string Name,
    string Category,
    decimal Price,
    int    StockLevel,
    double Similarity);
C#
// Endpoint
app.MapGet("/api/products/search", async (
    string q,
    ISender mediator,
    CancellationToken ct) =>
{
    var results = await mediator.Send(new SemanticProductSearchQuery(q), ct);
    return Results.Ok(results);
})
.RequireAuthorization()
.WithName("SemanticProductSearch");

Feature 2: AI Order Summary with Streaming

C#
// Streaming endpoint — tokens arrive in real time
app.MapGet("/api/orders/{id}/ai-summary", async (
    int id,
    ClaimsPrincipal user,
    ISender mediator,
    IChatClient chatClient,
    HttpContext ctx,
    CancellationToken ct) =>
{
    // Load the order
    var order = await mediator.Send(
        new GetOrderByIdQuery(id, user.GetUserId(), user.GetRole()), ct);

    if (order is null)
    {
        ctx.Response.StatusCode = 404;
        return;
    }

    // Stream the AI summary
    ctx.Response.Headers.ContentType  = "text/event-stream";
    ctx.Response.Headers.CacheControl = "no-cache";

    var prompt = $"""
        You are an OrderFlow customer service assistant.
        Write a friendly, concise summary of this order for the customer.
        Include status, items ordered, total, and any relevant next steps.

        Order details:
        - Order #{order.Id}
        - Status: {order.Status}
        - Total: {order.Total:C}
        - Items: {string.Join(", ", order.Lines.Select(l => $"{l.Quantity}x {l.ProductName}"))}
        - Placed: {order.CreatedAt:MMM d, yyyy}
        """;

    try
    {
        await foreach (var update in chatClient.CompleteStreamingAsync(
            [new ChatMessage(ChatRole.User, prompt)],
            cancellationToken: ct))
        {
            if (update.Text is { Length: > 0 } text)
            {
                await ctx.Response.WriteAsync(
                    $"data: {JsonSerializer.Serialize(text)}\n\n", ct);
                await ctx.Response.Body.FlushAsync(ct);
            }
        }

        await ctx.Response.WriteAsync("data: [DONE]\n\n", ct);
        await ctx.Response.Body.FlushAsync(ct);
    }
    catch (OperationCanceledException)
    {
        // Client disconnected — normal
    }
})
.RequireAuthorization();

Feature 3: Customer Support Chatbot

C#
// src/OrderFlow.Application/Support/SupportChatService.cs
public class SupportChatService(
    IChatClient chatClient,
    IOrderRepository orders,
    IProductRepository products)
{
    // Tools available to the chatbot
    [Description("Get the status of a customer's order")]
    private async Task<string> GetOrderStatus(
        [Description("The order ID")] int orderId,
        [Description("The customer ID making the request")] int customerId,
        CancellationToken ct = default)
    {
        var order = await orders.GetByIdAsync(orderId, ct);
        if (order is null) return $"Order {orderId} not found.";
        if (order.CustomerId != customerId)
            return "You are not authorised to view this order.";

        return $"Order {orderId}: Status={order.Status}, Total={order.Total:C}, " +
               $"Items={order.Lines.Count}, Created={order.CreatedAt:MMM d}";
    }

    [Description("Search products for the customer")]
    private async Task<string> SearchProducts(
        [Description("What the customer is looking for")] string query,
        CancellationToken ct = default)
    {
        var found = await products.SearchAsync(query, 5, ct);
        if (!found.Any()) return "No products found matching that description.";

        return string.Join("\n", found.Select(p =>
            $"- {p.Name} ({p.Category}): {p.Price:C} — {(p.StockLevel > 0 ? "In stock" : "Out of stock")}"));
    }

    public async IAsyncEnumerable<string> ChatAsync(
        int customerId,
        List<ChatMessage> conversationHistory,
        string userMessage,
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
    {
        var messages = new List<ChatMessage>
        {
            new(ChatRole.System, $"""
                You are a helpful customer service agent for OrderFlow.
                You are assisting customer ID {customerId}.
                Be concise, friendly, and always verify the customer owns any order they ask about.
                Use tools to look up real-time order status and product availability.
                """),
        };

        messages.AddRange(conversationHistory);
        messages.Add(new ChatMessage(ChatRole.User, userMessage));

        var options = new ChatOptions
        {
            Tools =
            [
                AIFunctionFactory.Create(
                    (int orderId, CancellationToken innerCt) =>
                        GetOrderStatus(orderId, customerId, innerCt)),
                AIFunctionFactory.Create(
                    (string query, CancellationToken innerCt) =>
                        SearchProducts(query, innerCt)),
            ],
            ToolMode = ChatToolMode.Auto,
        };

        await foreach (var update in chatClient.CompleteStreamingAsync(messages, options, ct))
        {
            if (update.Text is { Length: > 0 } text)
                yield return text;
        }
    }
}
C#
// Conversation session storage in Redis
public class ConversationStore(IDistributedCache cache)
{
    private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);

    public async Task<List<ChatMessage>> GetHistoryAsync(string sessionId, CancellationToken ct)
    {
        var json = await cache.GetStringAsync($"chat:{sessionId}", ct);
        return json is null
            ? []
            : JsonSerializer.Deserialize<List<ChatMessageDto>>(json)!
                .Select(d => new ChatMessage(
                    d.Role == "assistant" ? ChatRole.Assistant : ChatRole.User,
                    d.Content))
                .ToList();
    }

    public async Task AppendAsync(
        string sessionId, string role, string content, CancellationToken ct)
    {
        var history = await GetHistoryAsync(sessionId, ct);
        history.Add(new ChatMessage(
            role == "assistant" ? ChatRole.Assistant : ChatRole.User,
            content));

        // Keep last 20 messages (token management)
        if (history.Count > 20)
            history.RemoveRange(0, history.Count - 20);

        await cache.SetStringAsync(
            $"chat:{sessionId}",
            JsonSerializer.Serialize(history.Select(m => new ChatMessageDto(
                m.Role == ChatRole.Assistant ? "assistant" : "user",
                m.Text ?? ""))),
            new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = SessionTtl },
            ct);
    }
}

public record ChatMessageDto(string Role, string Content);
C#
// Streaming chatbot endpoint
app.MapPost("/api/support/chat", async (
    SupportChatRequest req,
    ClaimsPrincipal user,
    SupportChatService chat,
    ConversationStore conversations,
    HttpContext ctx,
    CancellationToken ct) =>
{
    var customerId = user.GetUserId();
    var sessionId  = req.SessionId ?? Guid.NewGuid().ToString();
    var history    = await conversations.GetHistoryAsync(sessionId, ct);

    ctx.Response.Headers.ContentType  = "text/event-stream";
    ctx.Response.Headers.CacheControl = "no-cache";

    // Send session ID so client can persist it
    await ctx.Response.WriteAsync(
        $"data: {JsonSerializer.Serialize(new { sessionId })}\n\n", ct);
    await ctx.Response.Body.FlushAsync(ct);

    var accumulated = new System.Text.StringBuilder();

    try
    {
        await foreach (var token in chat.ChatAsync(customerId, history, req.Message, ct))
        {
            accumulated.Append(token);
            await ctx.Response.WriteAsync(
                $"data: {JsonSerializer.Serialize(new { token })}\n\n", ct);
            await ctx.Response.Body.FlushAsync(ct);
        }
    }
    catch (OperationCanceledException) { }

    // Save both turns to conversation history
    await conversations.AppendAsync(sessionId, "user",      req.Message,             ct);
    await conversations.AppendAsync(sessionId, "assistant", accumulated.ToString(),   ct);

    await ctx.Response.WriteAsync("data: [DONE]\n\n", ct);
    await ctx.Response.Body.FlushAsync(ct);
})
.RequireAuthorization();

public record SupportChatRequest(string Message, string? SessionId = null);

What OrderFlow Can Now Do

Semantic search:
  GET /api/products/search?q=wireless noise cancelling headphones
  → returns headphones, earbuds, speakers sorted by semantic relevance
  → works even if the user said "earphones" and products say "earbuds"

AI summary:
  GET /api/orders/42/ai-summary
  → "Your order #42 for 2x Widget and 1x Gadget totalling £44.97 is currently
     Pending. It was placed on 25 May and should be dispatched within 1-2
     business days. You'll receive an email when it ships."

Chatbot:
  POST /api/support/chat
  → { "message": "Where is my order 42?", "sessionId": "abc123" }
  → Streams: "I can check that for you! Let me look up order 42..."
     [tool call: GetOrderStatus(42)]
  → Streams: "Order 42 is currently Paid and awaiting dispatch. Expected..."

OrderFlow: Complete

You've now built a production-ready .NET API with:

  • Clean Architecture (Core / Application / Infrastructure / API layers)
  • JWT authentication with refresh token rotation
  • CQRS with MediatR and validation pipeline
  • Domain events with the Outbox Pattern
  • Redis caching with automatic invalidation
  • A full test suite (unit + integration + API)
  • AI-powered semantic search, streaming summaries, and a support chatbot

Continue to the AI project guides for standalone AI application walkthroughs.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.