diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 5b1f498..a7707f9 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -41,7 +41,28 @@
"WebFetch(domain:www.kenmuse.com)",
"WebFetch(domain:blog.rsuter.com)",
"WebFetch(domain:natemcmaster.com)",
- "WebFetch(domain:www.nuget.org)"
+ "WebFetch(domain:www.nuget.org)",
+ "Bash(tree:*)",
+ "Bash(arch -x86_64 dotnet build:*)",
+ "Bash(brew install:*)",
+ "Bash(brew search:*)",
+ "Bash(ln:*)",
+ "Bash(ollama pull:*)",
+ "Bash(brew services start:*)",
+ "Bash(jq:*)",
+ "Bash(for:*)",
+ "Bash(do curl -s http://localhost:11434/api/tags)",
+ "Bash(time curl -X POST http://localhost:6001/api/command/executeAgent )",
+ "Bash(time curl -X POST http://localhost:6001/api/command/executeAgent -H \"Content-Type: application/json\" -d '{\"\"\"\"prompt\"\"\"\":\"\"\"\"What is 5 + 3?\"\"\"\"}')",
+ "Bash(time curl -s -X POST http://localhost:6001/api/command/executeAgent -H \"Content-Type: application/json\" -d '{\"\"\"\"prompt\"\"\"\":\"\"\"\"What is (5 + 3) multiplied by 2?\"\"\"\"}')",
+ "Bash(git push:*)",
+ "Bash(dotnet ef migrations add:*)",
+ "Bash(export PATH=\"$PATH:/Users/jean-philippebrule/.dotnet/tools\")",
+ "Bash(dotnet tool uninstall:*)",
+ "Bash(/Users/jean-philippebrule/.dotnet/tools/dotnet-ef migrations add InitialCreate --context AgentDbContext --output-dir Data/Migrations)",
+ "Bash(dotnet --info:*)",
+ "Bash(export DOTNET_ROOT=/Users/jean-philippebrule/.dotnet)",
+ "Bash(dotnet-ef migrations add:*)"
],
"deny": [],
"ask": []
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..4d5fbd8
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,50 @@
+# Git
+.git
+.gitignore
+.gitattributes
+
+# Docker
+docker-compose*.yml
+Dockerfile
+.dockerignore
+.env
+.env.*
+
+# IDE
+.vs
+.vscode
+.idea
+*.user
+*.suo
+
+# Build outputs
+**/bin/
+**/obj/
+**/out/
+artifacts/
+
+# NuGet
+*.nupkg
+*.snupkg
+packages/
+
+# Tests
+**/TestResults/
+
+# Documentation
+*.md
+docs/
+.github/
+
+# Rider
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Scripts (not needed in container)
+scripts/
+
+# Docker configs (not needed in container)
+docker/
diff --git a/.env b/.env
new file mode 100644
index 0000000..c2bef94
--- /dev/null
+++ b/.env
@@ -0,0 +1,30 @@
+# Langfuse API Keys (placeholder - will be generated after Langfuse UI setup)
+# IMPORTANT: After running docker-compose up, go to http://localhost:3000
+# Create an account, create a project, and copy the API keys here
+LANGFUSE_PUBLIC_KEY=pk-lf-placeholder-replace-after-setup
+LANGFUSE_SECRET_KEY=sk-lf-placeholder-replace-after-setup
+
+# Langfuse Internal Configuration (auto-generated)
+NEXTAUTH_SECRET=R3+DOKWiSpojMFKmD2/b0vNRedfWUaxantjEb/HVfQM=
+SALT=xAuyPdjUGep0WRfVXqLDrU9TTELiWOr3AgmyIiS4STQ=
+ENCRYPTION_KEY=91acdacf6b22ba4ad4dc5bec2a5fd0961ca89f161613a6b273162e0b5faaaffa
+
+# Database Configuration
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=postgres
+POSTGRES_DB=postgres
+
+# Connection Strings
+CONNECTION_STRING_SVRNTY=Host=postgres;Database=svrnty;Username=postgres;Password=postgres;Include Error Detail=true
+CONNECTION_STRING_LANGFUSE=postgresql://postgres:postgres@postgres:5432/langfuse
+
+# Ollama Configuration
+OLLAMA_BASE_URL=http://ollama:11434
+OLLAMA_MODEL=qwen2.5-coder:7b
+
+# API Configuration
+ASPNETCORE_ENVIRONMENT=Production
+ASPNETCORE_URLS=http://+:6001;http://+:6000
+
+# Langfuse Endpoint
+LANGFUSE_OTLP_ENDPOINT=http://langfuse:3000/api/public/otel/v1/traces
diff --git a/DEPLOYMENT_README.md b/DEPLOYMENT_README.md
new file mode 100644
index 0000000..8b51d47
--- /dev/null
+++ b/DEPLOYMENT_README.md
@@ -0,0 +1,75 @@
+
+## π Production Enhancements Added
+
+### Rate Limiting
+- **Limit**: 100 requests per minute per client
+- **Strategy**: Fixed window rate limiter
+- **Queue**: Up to 10 requests queued
+- **Response**: HTTP 429 with retry-after information
+
+### Prometheus Metrics
+- **Endpoint**: http://localhost:6001/metrics
+- **Metrics Collected**:
+ - HTTP request duration and count
+ - HTTP client request duration
+ - Custom application metrics
+- **Format**: Prometheus scraping format
+- **Integration**: Works with Grafana, Prometheus, or any monitoring tool
+
+### How to Monitor
+
+**Option 1: Prometheus + Grafana**
+```yaml
+# Add to docker-compose.yml
+prometheus:
+ image: prom/prometheus
+ ports:
+ - "9090:9090"
+ volumes:
+ - ./prometheus.yml:/etc/prometheus/prometheus.yml
+ command:
+ - '--config.file=/etc/prometheus/prometheus.yml'
+
+grafana:
+ image: grafana/grafana
+ ports:
+ - "3001:3000"
+```
+
+**Option 2: Direct Scraping**
+```bash
+# View raw metrics
+curl http://localhost:6001/metrics
+
+# Example metrics you'll see:
+# http_server_request_duration_seconds_bucket
+# http_server_request_duration_seconds_count
+# http_client_request_duration_seconds_bucket
+```
+
+### Rate Limiting Examples
+
+```bash
+# Test rate limiting
+for i in {1..105}; do
+ curl -X POST http://localhost:6001/api/command/executeAgent \
+ -H "Content-Type: application/json" \
+ -d '{"prompt":"test"}' &
+done
+
+# After 100 requests, you'll see:
+# {
+# "error": "Too many requests. Please try again later.",
+# "retryAfter": 60
+# }
+```
+
+### Monitoring Dashboard Metrics
+
+**Key Metrics to Watch:**
+- `http_server_request_duration_seconds` - API latency
+- `http_client_request_duration_seconds` - Ollama LLM latency
+- Request rate and error rate
+- Active connections
+- Rate limit rejections
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1b0ba03
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,51 @@
+# Build stage
+FROM mcr.microsoft.com/dotnet/sdk:10.0-preview AS build
+WORKDIR /src
+
+# Copy solution file
+COPY *.sln ./
+
+# Copy all project files
+COPY Svrnty.CQRS.Abstractions/*.csproj ./Svrnty.CQRS.Abstractions/
+COPY Svrnty.CQRS/*.csproj ./Svrnty.CQRS/
+COPY Svrnty.CQRS.MinimalApi/*.csproj ./Svrnty.CQRS.MinimalApi/
+COPY Svrnty.CQRS.FluentValidation/*.csproj ./Svrnty.CQRS.FluentValidation/
+COPY Svrnty.CQRS.DynamicQuery.Abstractions/*.csproj ./Svrnty.CQRS.DynamicQuery.Abstractions/
+COPY Svrnty.CQRS.DynamicQuery/*.csproj ./Svrnty.CQRS.DynamicQuery/
+COPY Svrnty.CQRS.DynamicQuery.MinimalApi/*.csproj ./Svrnty.CQRS.DynamicQuery.MinimalApi/
+COPY Svrnty.CQRS.Grpc.Abstractions/*.csproj ./Svrnty.CQRS.Grpc.Abstractions/
+COPY Svrnty.CQRS.Grpc/*.csproj ./Svrnty.CQRS.Grpc/
+COPY Svrnty.CQRS.Grpc.Generators/*.csproj ./Svrnty.CQRS.Grpc.Generators/
+COPY Svrnty.Sample/*.csproj ./Svrnty.Sample/
+
+# Restore dependencies
+RUN dotnet restore
+
+# Copy all source files
+COPY . .
+
+# Build and publish
+WORKDIR /src/Svrnty.Sample
+RUN dotnet publish -c Release -o /app/publish --no-restore
+
+# Runtime stage
+FROM mcr.microsoft.com/dotnet/aspnet:10.0-preview AS runtime
+WORKDIR /app
+
+# Install curl for health checks
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends curl && \
+ rm -rf /var/lib/apt/lists/*
+
+# Copy published application
+COPY --from=build /app/publish .
+
+# Expose ports
+EXPOSE 6000 6001
+
+# Set environment variables
+ENV ASPNETCORE_URLS=http://+:6001;http://+:6000
+ENV ASPNETCORE_ENVIRONMENT=Production
+
+# Run the application
+ENTRYPOINT ["dotnet", "Svrnty.Sample.dll"]
diff --git a/README.md b/README.md
index dc8d04e..f360aa9 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,6 @@
# CQRS
Our implementation of query and command responsibility segregation (CQRS).
-
## Getting Started
> Install nuget package to your awesome project.
diff --git a/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs b/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs
index d874593..88dd74e 100644
--- a/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs
+++ b/Svrnty.Sample/AI/Commands/ExecuteAgentCommandHandler.cs
@@ -1,16 +1,24 @@
+using System.Diagnostics;
+using System.Text.Json;
using Microsoft.Extensions.AI;
using Svrnty.CQRS.Abstractions;
using Svrnty.Sample.AI.Tools;
+using Svrnty.Sample.Data;
+using Svrnty.Sample.Data.Entities;
namespace Svrnty.Sample.AI.Commands;
///
-/// Handler for executing AI agent commands with function calling support
+/// Handler for executing AI agent commands with function calling support and full observability
///
-public class ExecuteAgentCommandHandler(IChatClient chatClient) : ICommandHandler
+public class ExecuteAgentCommandHandler(
+ IChatClient chatClient,
+ AgentDbContext dbContext,
+ MathTool mathTool,
+ DatabaseQueryTool dbTool,
+ ILogger logger) : ICommandHandler
{
- // In-memory conversation store (replace with proper persistence in production)
- private static readonly Dictionary> ConversationStore = new();
+ private static readonly ActivitySource ActivitySource = new("Svrnty.AI.Agent");
private const int MaxFunctionCallIterations = 10; // Prevent infinite loops
public async Task HandleAsync(
@@ -18,90 +26,139 @@ public class ExecuteAgentCommandHandler(IChatClient chatClient) : ICommandHandle
CancellationToken cancellationToken = default)
{
var conversationId = Guid.NewGuid();
- var messages = new List
+
+ // Start root trace
+ using var activity = ActivitySource.StartActivity("agent.execute", ActivityKind.Server);
+ activity?.SetTag("agent.conversation_id", conversationId);
+ activity?.SetTag("agent.prompt", command.Prompt);
+ activity?.SetTag("agent.model", "qwen2.5-coder:7b");
+
+ try
{
- new(ChatRole.User, command.Prompt)
- };
-
- // Register available tools
- var mathTool = new MathTool();
- var dbTool = new DatabaseQueryTool();
- var tools = new List
- {
- // Math tools
- AIFunctionFactory.Create(mathTool.Add),
- AIFunctionFactory.Create(mathTool.Multiply),
-
- // Business tools
- AIFunctionFactory.Create(dbTool.GetMonthlyRevenue),
- AIFunctionFactory.Create(dbTool.GetRevenueRange),
- AIFunctionFactory.Create(dbTool.CountCustomersByState),
- AIFunctionFactory.Create(dbTool.CountCustomersByTier),
- AIFunctionFactory.Create(dbTool.GetCustomers)
- };
-
- var options = new ChatOptions
- {
- ModelId = "qwen2.5-coder:7b",
- Tools = tools.Cast().ToList()
- };
-
- // Create function lookup by name for invocation
- var functionLookup = tools.ToDictionary(
- f => f.Metadata.Name,
- f => f,
- StringComparer.OrdinalIgnoreCase
- );
-
- // Initial AI completion
- var completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
- messages.Add(completion.Message);
-
- // Function calling loop - continue until no more function calls or max iterations
- var iterations = 0;
- while (completion.Message.Contents.OfType().Any() && iterations < MaxFunctionCallIterations)
- {
- iterations++;
-
- // Execute all function calls from the response
- foreach (var functionCall in completion.Message.Contents.OfType())
+ var messages = new List
{
- try
+ new(ChatRole.User, command.Prompt)
+ };
+
+ // Register available tools
+ using (var toolActivity = ActivitySource.StartActivity("tools.register"))
+ {
+ var tools = new List
{
- // Look up the actual function
- if (!functionLookup.TryGetValue(functionCall.Name, out var function))
+ AIFunctionFactory.Create(mathTool.Add),
+ AIFunctionFactory.Create(mathTool.Multiply),
+ AIFunctionFactory.Create(dbTool.GetMonthlyRevenue),
+ AIFunctionFactory.Create(dbTool.GetRevenueRange),
+ AIFunctionFactory.Create(dbTool.CountCustomersByState),
+ AIFunctionFactory.Create(dbTool.CountCustomersByTier),
+ AIFunctionFactory.Create(dbTool.GetCustomers)
+ };
+
+ toolActivity?.SetTag("tools.count", tools.Count);
+ toolActivity?.SetTag("tools.names", string.Join(",", tools.Select(t => t.Metadata.Name)));
+
+ var options = new ChatOptions
+ {
+ ModelId = "qwen2.5-coder:7b",
+ Tools = tools.Cast().ToList()
+ };
+
+ var functionLookup = tools.ToDictionary(
+ f => f.Metadata.Name,
+ f => f,
+ StringComparer.OrdinalIgnoreCase
+ );
+
+ // Initial AI completion
+ using (var llmActivity = ActivitySource.StartActivity("llm.completion"))
+ {
+ llmActivity?.SetTag("llm.iteration", 0);
+ var completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
+ messages.Add(completion.Message);
+
+ // Function calling loop
+ var iterations = 0;
+ while (completion.Message.Contents.OfType().Any()
+ && iterations < MaxFunctionCallIterations)
{
- throw new InvalidOperationException($"Function '{functionCall.Name}' not found");
+ iterations++;
+
+ foreach (var functionCall in completion.Message.Contents.OfType())
+ {
+ using var funcActivity = ActivitySource.StartActivity($"function.{functionCall.Name}");
+ funcActivity?.SetTag("function.name", functionCall.Name);
+ funcActivity?.SetTag("function.arguments", JsonSerializer.Serialize(functionCall.Arguments));
+
+ try
+ {
+ if (!functionLookup.TryGetValue(functionCall.Name, out var function))
+ {
+ throw new InvalidOperationException($"Function '{functionCall.Name}' not found");
+ }
+
+ var result = await function.InvokeAsync(functionCall.Arguments, cancellationToken);
+ funcActivity?.SetTag("function.result", result?.ToString() ?? "null");
+ funcActivity?.SetTag("function.success", true);
+
+ var toolMessage = new ChatMessage(ChatRole.Tool, result?.ToString() ?? "null");
+ toolMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, result));
+ messages.Add(toolMessage);
+ }
+ catch (Exception ex)
+ {
+ funcActivity?.SetTag("function.success", false);
+ funcActivity?.SetTag("error.message", ex.Message);
+
+ var errorMessage = new ChatMessage(ChatRole.Tool, $"Error: {ex.Message}");
+ errorMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, $"Error: {ex.Message}"));
+ messages.Add(errorMessage);
+ }
+ }
+
+ using (var nextLlmActivity = ActivitySource.StartActivity("llm.completion"))
+ {
+ nextLlmActivity?.SetTag("llm.iteration", iterations);
+ completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
+ messages.Add(completion.Message);
+ }
}
- // Invoke the function with arguments
- var result = await function.InvokeAsync(functionCall.Arguments, cancellationToken);
+ // Store conversation in database
+ var conversation = new Conversation
+ {
+ Id = conversationId,
+ Messages = messages.Select(m => new ConversationMessage
+ {
+ Role = m.Role.ToString(),
+ Content = m.Text ?? string.Empty,
+ Timestamp = DateTime.UtcNow
+ }).ToList()
+ };
- // Add function result to conversation as a tool message
- var toolMessage = new ChatMessage(ChatRole.Tool, result?.ToString() ?? "null");
- toolMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, result));
- messages.Add(toolMessage);
- }
- catch (Exception ex)
- {
- // Handle function call errors gracefully
- var errorMessage = new ChatMessage(ChatRole.Tool, $"Error executing {functionCall.Name}: {ex.Message}");
- errorMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, $"Error: {ex.Message}"));
- messages.Add(errorMessage);
+ dbContext.Conversations.Add(conversation);
+ await dbContext.SaveChangesAsync(cancellationToken);
+
+ activity?.SetTag("agent.success", true);
+ activity?.SetTag("agent.iterations", iterations);
+ activity?.SetTag("agent.response_preview", completion.Message.Text?.Substring(0, Math.Min(100, completion.Message.Text.Length)));
+
+ logger.LogInformation("Agent executed successfully for conversation {ConversationId}", conversationId);
+
+ return new AgentResponse(
+ Content: completion.Message.Text ?? "No response",
+ ConversationId: conversationId
+ );
}
}
-
- // Get next completion with function results
- completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
- messages.Add(completion.Message);
}
+ catch (Exception ex)
+ {
+ activity?.SetTag("agent.success", false);
+ activity?.SetTag("error.type", ex.GetType().Name);
+ activity?.SetTag("error.message", ex.Message);
- // Store conversation for potential future use
- ConversationStore[conversationId] = messages;
-
- return new AgentResponse(
- Content: completion.Message.Text ?? "No response",
- ConversationId: conversationId
- );
+ logger.LogError(ex, "Agent execution failed for conversation {ConversationId}", conversationId);
+ throw;
+ }
}
}
diff --git a/Svrnty.Sample/AI/OllamaClient.cs b/Svrnty.Sample/AI/OllamaClient.cs
index d55f8c9..bbca717 100644
--- a/Svrnty.Sample/AI/OllamaClient.cs
+++ b/Svrnty.Sample/AI/OllamaClient.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics;
using Microsoft.Extensions.AI;
using System.Text.Json;
@@ -5,6 +6,8 @@ namespace Svrnty.Sample.AI;
public sealed class OllamaClient(HttpClient http) : IChatClient
{
+ private static readonly ActivitySource ActivitySource = new("Svrnty.AI.Ollama");
+
public ChatClientMetadata Metadata => new("ollama", new Uri("http://localhost:11434"));
public async Task CompleteAsync(
@@ -12,6 +15,13 @@ public sealed class OllamaClient(HttpClient http) : IChatClient
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
+ using var activity = ActivitySource.StartActivity("ollama.chat", ActivityKind.Client);
+ activity?.SetTag("ollama.model", options?.ModelId ?? "qwen2.5-coder:7b");
+ activity?.SetTag("ollama.message_count", messages.Count);
+ activity?.SetTag("ollama.has_tools", options?.Tools?.Any() ?? false);
+
+ var startTime = DateTime.UtcNow;
+
// Build messages array including tool results
var ollamaMessages = messages.Select(m => new
{
@@ -90,6 +100,11 @@ public sealed class OllamaClient(HttpClient http) : IChatClient
}
}
+ var latency = (DateTime.UtcNow - startTime).TotalMilliseconds;
+ activity?.SetTag("ollama.latency_ms", latency);
+ activity?.SetTag("ollama.estimated_tokens", content.Length / 4);
+ activity?.SetTag("ollama.has_function_calls", chatMessage.Contents.OfType().Any());
+
return new ChatCompletion(chatMessage);
}
diff --git a/Svrnty.Sample/DEPLOYMENT_README.md b/Svrnty.Sample/DEPLOYMENT_README.md
new file mode 100644
index 0000000..416cb6f
--- /dev/null
+++ b/Svrnty.Sample/DEPLOYMENT_README.md
@@ -0,0 +1,120 @@
+# AI Agent Production Deployment
+
+Complete production-ready AI agent system with Langfuse observability, PostgreSQL persistence, and Docker deployment.
+
+## Architecture
+
+- **AI Agent API** (.NET 10) - Ports 6000 (gRPC), 6001 (HTTP)
+- **PostgreSQL** - Database for conversations, revenue, and customer data
+- **Ollama** - Local LLM (qwen2.5-coder:7b)
+- **Langfuse** - Observability and tracing UI
+
+## Quick Start
+
+```bash
+# 1. Deploy everything
+./scripts/deploy.sh
+
+# 2. Configure Langfuse (one-time setup)
+# - Open http://localhost:3000
+# - Create account and project
+# - Copy API keys from Settings β API Keys
+# - Update .env with your keys
+# - Restart API: docker compose restart api
+
+# 3. Test the agent
+curl -X POST http://localhost:6001/api/command/executeAgent \
+ -H "Content-Type: application/json" \
+ -d '{"prompt":"What is 5 + 3?"}'
+
+# 4. View traces
+# Open http://localhost:3000/traces
+```
+
+## Features
+
+β
**Full Observability**: OpenTelemetry traces sent to Langfuse
+β
**Database Persistence**: Conversations stored in PostgreSQL
+β
**Function Calling**: Math and database query tools
+β
**Health Checks**: `/health` and `/health/ready` endpoints
+β
**Auto Migrations**: Database schema applied on startup
+β
**Production Ready**: Docker Compose multi-container setup
+
+## Access Points
+
+- HTTP API: http://localhost:6001/api/command/executeAgent
+- Swagger: http://localhost:6001/swagger
+- Langfuse: http://localhost:3000
+- Ollama: http://localhost:11434
+
+## Project Structure
+
+```
+βββ docker-compose.yml # Multi-container orchestration
+βββ Dockerfile # Multi-stage .NET build
+βββ .env # Configuration (secrets)
+βββ docker/configs/
+β βββ init-db.sql # PostgreSQL initialization
+βββ Svrnty.Sample/
+β βββ AI/
+β β βββ OllamaClient.cs # Instrumented LLM client
+β β βββ Commands/
+β β β βββ ExecuteAgent* # Main handler (instrumented)
+β β βββ Tools/
+β β βββ MathTool.cs # Math operations
+β β βββ DatabaseQuery* # SQL queries
+β βββ Data/
+β β βββ AgentDbContext.cs # EF Core context
+β β βββ Entities/ # Conversation, Revenue, Customer
+β β βββ Migrations/ # EF migrations
+β βββ Program.cs # Startup (OpenTelemetry, Health Checks)
+βββ scripts/
+ βββ deploy.sh # One-command deployment
+```
+
+## OpenTelemetry Spans
+
+The system creates nested spans for complete observability:
+
+- `agent.execute` - Root span for entire agent execution
+ - `tools.register` - Tool registration
+ - `llm.completion` - Each LLM call
+ - `function.{name}` - Each tool invocation
+
+Tags include: conversation_id, prompt, model, success, latency, tokens
+
+## Database Schema
+
+**agent.conversations** - AI conversation history
+**agent.revenue** - Monthly revenue data (seeded)
+**agent.customers** - Customer data (seeded)
+
+## Troubleshooting
+
+```bash
+# Check service health
+docker compose ps
+curl http://localhost:6001/health
+
+# View logs
+docker compose logs api
+docker compose logs ollama
+docker compose logs langfuse
+
+# Restart services
+docker compose restart api
+
+# Full reset
+docker compose down -v
+./scripts/deploy.sh
+```
+
+## Implementation Details
+
+- **OpenTelemetry**: Exports traces to Langfuse via OTLP/HTTP
+- **ActivitySource**: "Svrnty.AI.Agent" and "Svrnty.AI.Ollama"
+- **Database**: Auto-migration on startup, seeded with sample data
+- **Error Handling**: Graceful function call failures, structured logging
+- **Performance**: Multi-stage Docker builds, health checks with retries
+
+## Estimated Time: 3-4 hours for complete implementation
diff --git a/Svrnty.Sample/Data/AgentDbContext.cs b/Svrnty.Sample/Data/AgentDbContext.cs
new file mode 100644
index 0000000..13fe2e3
--- /dev/null
+++ b/Svrnty.Sample/Data/AgentDbContext.cs
@@ -0,0 +1,58 @@
+using Microsoft.EntityFrameworkCore;
+using Svrnty.Sample.Data.Entities;
+
+namespace Svrnty.Sample.Data;
+
+///
+/// Database context for AI agent system with conversation history and business data
+///
+public class AgentDbContext : DbContext
+{
+ public AgentDbContext(DbContextOptions options) : base(options)
+ {
+ }
+
+ public DbSet Conversations => Set();
+ public DbSet Revenues => Set();
+ public DbSet Customers => Set();
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ base.OnModelCreating(modelBuilder);
+
+ // Configure Conversation entity
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.CreatedAt).HasDatabaseName("idx_conversations_created");
+ entity.HasIndex(e => e.UpdatedAt).HasDatabaseName("idx_conversations_updated");
+
+ entity.Property(e => e.MessagesJson)
+ .HasColumnType("jsonb")
+ .IsRequired()
+ .HasDefaultValue("[]");
+ });
+
+ // Configure Revenue entity
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => new { e.Month, e.Year })
+ .HasDatabaseName("idx_revenue_month")
+ .IsUnique();
+
+ entity.Property(e => e.Amount)
+ .HasPrecision(18, 2);
+ });
+
+ // Configure Customer entity
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.State).HasDatabaseName("idx_customers_state");
+ entity.HasIndex(e => e.Tier).HasDatabaseName("idx_customers_tier");
+ entity.HasIndex(e => new { e.State, e.Tier })
+ .HasDatabaseName("idx_customers_state_tier");
+ });
+ }
+}
diff --git a/Svrnty.Sample/Data/AgentDbContextFactory.cs b/Svrnty.Sample/Data/AgentDbContextFactory.cs
new file mode 100644
index 0000000..6e17795
--- /dev/null
+++ b/Svrnty.Sample/Data/AgentDbContextFactory.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace Svrnty.Sample.Data;
+
+///
+/// Design-time factory for creating AgentDbContext during migrations
+///
+public class AgentDbContextFactory : IDesignTimeDbContextFactory
+{
+ public AgentDbContext CreateDbContext(string[] args)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+
+ // Use a default connection string for design-time operations
+ // This will be overridden at runtime with the actual connection string from configuration
+ var connectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING_SVRNTY")
+ ?? "Host=localhost;Database=svrnty;Username=postgres;Password=postgres;Include Error Detail=true";
+
+ optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
+ {
+ npgsqlOptions.MigrationsHistoryTable("__EFMigrationsHistory", "agent");
+ });
+
+ return new AgentDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/Svrnty.Sample/Data/Entities/Conversation.cs b/Svrnty.Sample/Data/Entities/Conversation.cs
new file mode 100644
index 0000000..8038daa
--- /dev/null
+++ b/Svrnty.Sample/Data/Entities/Conversation.cs
@@ -0,0 +1,53 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json;
+
+namespace Svrnty.Sample.Data.Entities;
+
+///
+/// Represents an AI agent conversation with message history
+///
+[Table("conversations", Schema = "agent")]
+public class Conversation
+{
+ [Key]
+ [Column("id")]
+ public Guid Id { get; set; } = Guid.NewGuid();
+
+ ///
+ /// JSON array of messages in the conversation
+ ///
+ [Column("messages", TypeName = "jsonb")]
+ [Required]
+ public string MessagesJson { get; set; } = "[]";
+
+ [Column("created_at")]
+ [Required]
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+
+ [Column("updated_at")]
+ [Required]
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+
+ ///
+ /// Convenience property to get/set messages as objects (not mapped to database)
+ ///
+ [NotMapped]
+ public List Messages
+ {
+ get => string.IsNullOrEmpty(MessagesJson)
+ ? new List()
+ : JsonSerializer.Deserialize>(MessagesJson) ?? new List();
+ set => MessagesJson = JsonSerializer.Serialize(value);
+ }
+}
+
+///
+/// Individual message in a conversation
+///
+public class ConversationMessage
+{
+ public string Role { get; set; } = string.Empty;
+ public string Content { get; set; } = string.Empty;
+ public DateTime Timestamp { get; set; } = DateTime.UtcNow;
+}
diff --git a/Svrnty.Sample/Data/Entities/Customer.cs b/Svrnty.Sample/Data/Entities/Customer.cs
new file mode 100644
index 0000000..c692237
--- /dev/null
+++ b/Svrnty.Sample/Data/Entities/Customer.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Svrnty.Sample.Data.Entities;
+
+///
+/// Represents a customer in the system
+///
+[Table("customers", Schema = "agent")]
+public class Customer
+{
+ [Key]
+ [Column("id")]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; set; }
+
+ [Column("name")]
+ [Required]
+ [MaxLength(200)]
+ public string Name { get; set; } = string.Empty;
+
+ [Column("email")]
+ [MaxLength(200)]
+ public string? Email { get; set; }
+
+ [Column("state")]
+ [MaxLength(100)]
+ public string? State { get; set; }
+
+ [Column("tier")]
+ [MaxLength(50)]
+ public string? Tier { get; set; }
+
+ [Column("created_at")]
+ [Required]
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/Svrnty.Sample/Data/Entities/Revenue.cs b/Svrnty.Sample/Data/Entities/Revenue.cs
new file mode 100644
index 0000000..a2765fa
--- /dev/null
+++ b/Svrnty.Sample/Data/Entities/Revenue.cs
@@ -0,0 +1,33 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Svrnty.Sample.Data.Entities;
+
+///
+/// Represents monthly revenue data
+///
+[Table("revenue", Schema = "agent")]
+public class Revenue
+{
+ [Key]
+ [Column("id")]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int Id { get; set; }
+
+ [Column("month")]
+ [Required]
+ [MaxLength(50)]
+ public string Month { get; set; } = string.Empty;
+
+ [Column("amount", TypeName = "decimal(18,2)")]
+ [Required]
+ public decimal Amount { get; set; }
+
+ [Column("year")]
+ [Required]
+ public int Year { get; set; }
+
+ [Column("created_at")]
+ [Required]
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.Designer.cs b/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.Designer.cs
new file mode 100644
index 0000000..03beabe
--- /dev/null
+++ b/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.Designer.cs
@@ -0,0 +1,148 @@
+ο»Ώ//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Svrnty.Sample.Data;
+
+#nullable disable
+
+namespace Svrnty.Sample.Data.Migrations
+{
+ [DbContext(typeof(AgentDbContext))]
+ [Migration("20251108154325_InitialCreate")]
+ partial class InitialCreate
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Conversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("MessagesJson")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasDefaultValue("[]")
+ .HasColumnName("messages");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt")
+ .HasDatabaseName("idx_conversations_created");
+
+ b.HasIndex("UpdatedAt")
+ .HasDatabaseName("idx_conversations_updated");
+
+ b.ToTable("conversations", "agent");
+ });
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("State")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("state");
+
+ b.Property("Tier")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("tier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("State")
+ .HasDatabaseName("idx_customers_state");
+
+ b.HasIndex("Tier")
+ .HasDatabaseName("idx_customers_tier");
+
+ b.HasIndex("State", "Tier")
+ .HasDatabaseName("idx_customers_state_tier");
+
+ b.ToTable("customers", "agent");
+ });
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Revenue", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)")
+ .HasColumnName("amount");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Month")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("month");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Month", "Year")
+ .IsUnique()
+ .HasDatabaseName("idx_revenue_month");
+
+ b.ToTable("revenue", "agent");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.cs b/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.cs
new file mode 100644
index 0000000..52103fc
--- /dev/null
+++ b/Svrnty.Sample/Data/Migrations/20251108154325_InitialCreate.cs
@@ -0,0 +1,122 @@
+ο»Ώusing System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Svrnty.Sample.Data.Migrations
+{
+ ///
+ public partial class InitialCreate : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.EnsureSchema(
+ name: "agent");
+
+ migrationBuilder.CreateTable(
+ name: "conversations",
+ schema: "agent",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ messages = table.Column(type: "jsonb", nullable: false, defaultValue: "[]"),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_conversations", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "customers",
+ schema: "agent",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false),
+ email = table.Column(type: "character varying(200)", maxLength: 200, nullable: true),
+ state = table.Column(type: "character varying(100)", maxLength: 100, nullable: true),
+ tier = table.Column(type: "character varying(50)", maxLength: 50, nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_customers", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "revenue",
+ schema: "agent",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ month = table.Column(type: "character varying(50)", maxLength: 50, nullable: false),
+ amount = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ year = table.Column(type: "integer", nullable: false),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_revenue", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "idx_conversations_created",
+ schema: "agent",
+ table: "conversations",
+ column: "created_at");
+
+ migrationBuilder.CreateIndex(
+ name: "idx_conversations_updated",
+ schema: "agent",
+ table: "conversations",
+ column: "updated_at");
+
+ migrationBuilder.CreateIndex(
+ name: "idx_customers_state",
+ schema: "agent",
+ table: "customers",
+ column: "state");
+
+ migrationBuilder.CreateIndex(
+ name: "idx_customers_state_tier",
+ schema: "agent",
+ table: "customers",
+ columns: new[] { "state", "tier" });
+
+ migrationBuilder.CreateIndex(
+ name: "idx_customers_tier",
+ schema: "agent",
+ table: "customers",
+ column: "tier");
+
+ migrationBuilder.CreateIndex(
+ name: "idx_revenue_month",
+ schema: "agent",
+ table: "revenue",
+ columns: new[] { "month", "year" },
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "conversations",
+ schema: "agent");
+
+ migrationBuilder.DropTable(
+ name: "customers",
+ schema: "agent");
+
+ migrationBuilder.DropTable(
+ name: "revenue",
+ schema: "agent");
+ }
+ }
+}
diff --git a/Svrnty.Sample/Data/Migrations/AgentDbContextModelSnapshot.cs b/Svrnty.Sample/Data/Migrations/AgentDbContextModelSnapshot.cs
new file mode 100644
index 0000000..0ae97ff
--- /dev/null
+++ b/Svrnty.Sample/Data/Migrations/AgentDbContextModelSnapshot.cs
@@ -0,0 +1,145 @@
+ο»Ώ//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Svrnty.Sample.Data;
+
+#nullable disable
+
+namespace Svrnty.Sample.Data.Migrations
+{
+ [DbContext(typeof(AgentDbContext))]
+ partial class AgentDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Conversation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("MessagesJson")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasDefaultValue("[]")
+ .HasColumnName("messages");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt")
+ .HasDatabaseName("idx_conversations_created");
+
+ b.HasIndex("UpdatedAt")
+ .HasDatabaseName("idx_conversations_updated");
+
+ b.ToTable("conversations", "agent");
+ });
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Customer", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("email");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("name");
+
+ b.Property("State")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("state");
+
+ b.Property("Tier")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("tier");
+
+ b.HasKey("Id");
+
+ b.HasIndex("State")
+ .HasDatabaseName("idx_customers_state");
+
+ b.HasIndex("Tier")
+ .HasDatabaseName("idx_customers_tier");
+
+ b.HasIndex("State", "Tier")
+ .HasDatabaseName("idx_customers_state_tier");
+
+ b.ToTable("customers", "agent");
+ });
+
+ modelBuilder.Entity("Svrnty.Sample.Data.Entities.Revenue", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Amount")
+ .HasPrecision(18, 2)
+ .HasColumnType("decimal(18,2)")
+ .HasColumnName("amount");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Month")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("month");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Month", "Year")
+ .IsUnique()
+ .HasDatabaseName("idx_revenue_month");
+
+ b.ToTable("revenue", "agent");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Svrnty.Sample/Program.cs b/Svrnty.Sample/Program.cs
index 759c616..1abaa93 100644
--- a/Svrnty.Sample/Program.cs
+++ b/Svrnty.Sample/Program.cs
@@ -1,11 +1,21 @@
+using System.Text;
+using System.Threading.RateLimiting;
+using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Server.Kestrel.Core;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.AI;
+using OpenTelemetry;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Resources;
+using OpenTelemetry.Trace;
using Svrnty.CQRS;
using Svrnty.CQRS.FluentValidation;
using Svrnty.CQRS.Grpc;
using Svrnty.Sample;
using Svrnty.Sample.AI;
using Svrnty.Sample.AI.Commands;
+using Svrnty.Sample.AI.Tools;
+using Svrnty.Sample.Data;
using Svrnty.CQRS.MinimalApi;
using Svrnty.CQRS.DynamicQuery;
using Svrnty.CQRS.Abstractions;
@@ -21,16 +31,112 @@ builder.WebHost.ConfigureKestrel(options =>
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1);
});
+// Configure Database
+var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
+ ?? "Host=localhost;Database=svrnty;Username=postgres;Password=postgres;Include Error Detail=true";
+builder.Services.AddDbContext(options =>
+ options.UseNpgsql(connectionString));
+
+// Configure OpenTelemetry with Langfuse + Prometheus Metrics
+var langfusePublicKey = builder.Configuration["Langfuse:PublicKey"] ?? "";
+var langfuseSecretKey = builder.Configuration["Langfuse:SecretKey"] ?? "";
+var langfuseOtlpEndpoint = builder.Configuration["Langfuse:OtlpEndpoint"]
+ ?? "http://localhost:3000/api/public/otel/v1/traces";
+
+var otelBuilder = builder.Services.AddOpenTelemetry()
+ .ConfigureResource(resource => resource
+ .AddService(
+ serviceName: "svrnty-ai-agent",
+ serviceVersion: "1.0.0",
+ serviceInstanceId: Environment.MachineName)
+ .AddAttributes(new Dictionary
+ {
+ ["deployment.environment"] = builder.Environment.EnvironmentName,
+ ["service.namespace"] = "ai-agents",
+ ["host.name"] = Environment.MachineName
+ }));
+
+// Add Metrics (always enabled - Prometheus endpoint)
+otelBuilder.WithMetrics(metrics =>
+{
+ metrics
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddPrometheusExporter();
+});
+
+// Add Tracing (only when Langfuse keys are configured)
+if (!string.IsNullOrEmpty(langfusePublicKey) && !string.IsNullOrEmpty(langfuseSecretKey))
+{
+ var authString = Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{langfusePublicKey}:{langfuseSecretKey}"));
+
+ otelBuilder.WithTracing(tracing =>
+ {
+ tracing
+ .AddSource("Svrnty.AI.*")
+ .SetSampler(new AlwaysOnSampler())
+ .AddHttpClientInstrumentation(options =>
+ {
+ options.FilterHttpRequestMessage = (req) =>
+ !req.RequestUri?.Host.Contains("langfuse") ?? true;
+ })
+ .AddEntityFrameworkCoreInstrumentation(options =>
+ {
+ options.SetDbStatementForText = true;
+ options.SetDbStatementForStoredProcedure = true;
+ })
+ .AddOtlpExporter(options =>
+ {
+ options.Endpoint = new Uri(langfuseOtlpEndpoint);
+ options.Headers = $"Authorization=Basic {authString}";
+ options.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
+ });
+ });
+}
+
+// Configure Rate Limiting
+builder.Services.AddRateLimiter(options =>
+{
+ options.GlobalLimiter = PartitionedRateLimiter.Create(
+ context => RateLimitPartition.GetFixedWindowLimiter(
+ partitionKey: context.User.Identity?.Name ?? context.Request.Headers.Host.ToString(),
+ factory: _ => new FixedWindowRateLimiterOptions
+ {
+ PermitLimit = 100,
+ Window = TimeSpan.FromMinutes(1),
+ QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
+ QueueLimit = 10
+ }));
+
+ options.OnRejected = async (context, cancellationToken) =>
+ {
+ context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
+ await context.HttpContext.Response.WriteAsJsonAsync(new
+ {
+ error = "Too many requests. Please try again later.",
+ retryAfter = context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)
+ ? retryAfter.TotalSeconds
+ : 60
+ }, cancellationToken);
+ };
+});
+
// IMPORTANT: Register dynamic query dependencies FIRST
// (before AddSvrntyCqrs, so gRPC services can find the handlers)
builder.Services.AddTransient();
builder.Services.AddTransient();
builder.Services.AddDynamicQueryWithProvider();
+// Register AI Tools
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
// Register Ollama AI client
+var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434";
builder.Services.AddHttpClient(client =>
{
- client.BaseAddress = new Uri("http://localhost:11434");
+ client.BaseAddress = new Uri(ollamaBaseUrl);
});
// Register commands and queries with validators
@@ -59,46 +165,56 @@ builder.Services.AddSvrntyCqrs(cqrs =>
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
+// Configure Health Checks
+builder.Services.AddHealthChecks()
+ .AddNpgSql(connectionString, name: "postgresql", tags: new[] { "ready", "db" });
+
var app = builder.Build();
+// Run database migrations
+using (var scope = app.Services.CreateScope())
+{
+ var dbContext = scope.ServiceProvider.GetRequiredService();
+ try
+ {
+ await dbContext.Database.MigrateAsync();
+ Console.WriteLine("β
Database migrations applied successfully");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"β οΈ Database migration failed: {ex.Message}");
+ }
+}
+
+// Enable rate limiting
+app.UseRateLimiter();
+
// Map all configured CQRS endpoints (gRPC, MinimalApi, and Dynamic Queries)
app.UseSvrntyCqrs();
app.UseSwagger();
app.UseSwaggerUI();
+// Prometheus metrics endpoint
+app.MapPrometheusScrapingEndpoint();
+
// Health check endpoints
-app.MapGet("/health", () => Results.Ok(new { status = "healthy" }))
- .WithTags("Health");
-
-app.MapGet("/health/ready", async (IChatClient client) =>
+app.MapHealthChecks("/health");
+app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
- try
- {
- var testMessages = new List { new(ChatRole.User, "ping") };
- var response = await client.CompleteAsync(testMessages);
- return Results.Ok(new
- {
- status = "ready",
- ollama = "connected",
- responseTime = response != null ? "ok" : "slow"
- });
- }
- catch (Exception ex)
- {
- return Results.Json(new
- {
- status = "not_ready",
- ollama = "disconnected",
- error = ex.Message
- }, statusCode: 503);
- }
-})
-.WithTags("Health");
+ Predicate = check => check.Tags.Contains("ready")
+});
-Console.WriteLine("Auto-Generated gRPC Server with Reflection, Validation, MinimalApi and Swagger");
-Console.WriteLine("gRPC (HTTP/2): http://localhost:6000");
-Console.WriteLine("HTTP API (HTTP/1.1): http://localhost:6001/api/command/* and http://localhost:6001/api/query/*");
-Console.WriteLine("Swagger UI: http://localhost:6001/swagger");
+Console.WriteLine("Production-Ready AI Agent with Full Observability");
+Console.WriteLine("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ");
+Console.WriteLine("gRPC (HTTP/2): http://localhost:6000");
+Console.WriteLine("HTTP API (HTTP/1.1): http://localhost:6001/api/command/* and /api/query/*");
+Console.WriteLine("Swagger UI: http://localhost:6001/swagger");
+Console.WriteLine("Prometheus Metrics: http://localhost:6001/metrics");
+Console.WriteLine("Health Check: http://localhost:6001/health");
+Console.WriteLine("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ");
+Console.WriteLine($"Rate Limiting: 100 requests/minute per client");
+Console.WriteLine($"Langfuse Tracing: {(!string.IsNullOrEmpty(langfusePublicKey) ? "Enabled" : "Disabled (configure keys in .env)")}");
+Console.WriteLine("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ");
app.Run();
diff --git a/Svrnty.Sample/Svrnty.Sample.csproj b/Svrnty.Sample/Svrnty.Sample.csproj
index 34bca67..f2c4b42 100644
--- a/Svrnty.Sample/Svrnty.Sample.csproj
+++ b/Svrnty.Sample/Svrnty.Sample.csproj
@@ -13,6 +13,7 @@
+
@@ -20,8 +21,20 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/Svrnty.Sample/appsettings.Production.json b/Svrnty.Sample/appsettings.Production.json
new file mode 100644
index 0000000..22438fd
--- /dev/null
+++ b/Svrnty.Sample/appsettings.Production.json
@@ -0,0 +1,34 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ConnectionStrings": {
+ "DefaultConnection": "Host=postgres;Database=svrnty;Username=postgres;Password=postgres;Include Error Detail=true"
+ },
+ "Ollama": {
+ "BaseUrl": "http://ollama:11434",
+ "Model": "qwen2.5-coder:7b"
+ },
+ "Langfuse": {
+ "PublicKey": "",
+ "SecretKey": "",
+ "OtlpEndpoint": "http://langfuse:3000/api/public/otel/v1/traces"
+ },
+ "Kestrel": {
+ "Endpoints": {
+ "Grpc": {
+ "Url": "http://0.0.0.0:6000",
+ "Protocols": "Http2"
+ },
+ "Http": {
+ "Url": "http://0.0.0.0:6001",
+ "Protocols": "Http1"
+ }
+ }
+ }
+}
diff --git a/Svrnty.Sample/scripts/deploy.sh b/Svrnty.Sample/scripts/deploy.sh
new file mode 100755
index 0000000..282b8ee
--- /dev/null
+++ b/Svrnty.Sample/scripts/deploy.sh
@@ -0,0 +1,80 @@
+#!/bin/bash
+
+set -e
+
+echo "π Starting Complete AI Agent Stack with Observability"
+echo ""
+
+# Check prerequisites
+command -v docker >/dev/null 2>&1 || { echo "β Docker required but not installed." >&2; exit 1; }
+command -v docker compose >/dev/null 2>&1 || { echo "β Docker Compose required but not installed." >&2; exit 1; }
+
+# Load environment variables
+if [ ! -f .env ]; then
+ echo "β .env file not found!"
+ exit 1
+fi
+
+echo "π¦ Building .NET application..."
+docker compose build api
+
+echo ""
+echo "π§ Starting infrastructure services..."
+docker compose up -d postgres
+echo "β³ Waiting for PostgreSQL to be healthy..."
+sleep 10
+
+docker compose up -d langfuse ollama
+echo "β³ Waiting for services to initialize..."
+sleep 20
+
+echo ""
+echo "π€ Pulling Ollama model (this may take a few minutes)..."
+docker exec ollama ollama pull qwen2.5-coder:7b || echo "β οΈ Model pull failed, will retry on first request"
+
+echo ""
+echo "π Starting API service..."
+docker compose up -d api
+
+echo ""
+echo "π Waiting for all services to be healthy..."
+for i in {1..30}; do
+ api_health=$(curl -f -s http://localhost:6001/health 2>/dev/null || echo "fail")
+ langfuse_health=$(curl -f -s http://localhost:3000/api/health 2>/dev/null || echo "fail")
+ ollama_health=$(curl -f -s http://localhost:11434/api/tags 2>/dev/null || echo "fail")
+
+ if [ "$api_health" != "fail" ] && [ "$langfuse_health" != "fail" ] && [ "$ollama_health" != "fail" ]; then
+ echo "β
All services are healthy!"
+ break
+ fi
+ echo " Waiting for services... ($i/30)"
+ sleep 5
+done
+
+echo ""
+echo "π Services Status:"
+docker compose ps
+
+echo ""
+echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
+echo "π― Access Points:"
+echo " β’ HTTP API: http://localhost:6001/api/command/executeAgent"
+echo " β’ Swagger: http://localhost:6001/swagger"
+echo " β’ Langfuse UI: http://localhost:3000"
+echo " β’ Ollama: http://localhost:11434"
+echo ""
+echo "π Next Steps:"
+echo "1. Open Langfuse UI at http://localhost:3000"
+echo "2. Create an account and project"
+echo "3. Go to Settings β API Keys"
+echo "4. Copy the keys and update .env file:"
+echo " LANGFUSE_PUBLIC_KEY=pk-lf-your-key"
+echo " LANGFUSE_SECRET_KEY=sk-lf-your-key"
+echo "5. Restart API: docker compose restart api"
+echo ""
+echo "π§ͺ Test the agent:"
+echo " curl -X POST http://localhost:6001/api/command/executeAgent \\"
+echo " -H 'Content-Type: application/json' \\"
+echo " -d '{\"prompt\":\"What is 5 + 3?\"}'"
+echo ""
+echo "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..f977c11
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,119 @@
+version: '3.9'
+
+services:
+ # === .NET AI AGENT API ===
+ api:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: svrnty-api
+ ports:
+ - "6000:6000" # gRPC
+ - "6001:6001" # HTTP
+ environment:
+ - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
+ - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:6001;http://+:6000}
+ - ConnectionStrings__DefaultConnection=${CONNECTION_STRING_SVRNTY}
+ - Ollama__BaseUrl=${OLLAMA_BASE_URL}
+ - Ollama__Model=${OLLAMA_MODEL}
+ - Langfuse__PublicKey=${LANGFUSE_PUBLIC_KEY}
+ - Langfuse__SecretKey=${LANGFUSE_SECRET_KEY}
+ - Langfuse__OtlpEndpoint=${LANGFUSE_OTLP_ENDPOINT}
+ depends_on:
+ postgres:
+ condition: service_healthy
+ ollama:
+ condition: service_started
+ langfuse:
+ condition: service_healthy
+ networks:
+ - agent-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:6001/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 40s
+ restart: unless-stopped
+
+ # === OLLAMA LLM ===
+ ollama:
+ image: ollama/ollama:latest
+ container_name: ollama
+ ports:
+ - "11434:11434"
+ volumes:
+ - ollama_models:/root/.ollama
+ environment:
+ - OLLAMA_HOST=0.0.0.0
+ networks:
+ - agent-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:11434/api/tags"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 10s
+ restart: unless-stopped
+
+ # === LANGFUSE OBSERVABILITY ===
+ langfuse:
+ image: langfuse/langfuse:latest
+ container_name: langfuse
+ ports:
+ - "3000:3000"
+ environment:
+ - DATABASE_URL=${CONNECTION_STRING_LANGFUSE}
+ - DIRECT_URL=${CONNECTION_STRING_LANGFUSE}
+ - NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
+ - SALT=${SALT}
+ - ENCRYPTION_KEY=${ENCRYPTION_KEY}
+ - LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=true
+ - NEXTAUTH_URL=http://localhost:3000
+ - TELEMETRY_ENABLED=false
+ - NODE_ENV=production
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - agent-network
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
+ interval: 30s
+ timeout: 10s
+ retries: 5
+ start_period: 60s
+ restart: unless-stopped
+
+ # === POSTGRESQL DATABASE ===
+ postgres:
+ image: postgres:15-alpine
+ container_name: postgres
+ environment:
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_DB=${POSTGRES_DB}
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./docker/configs/init-db.sql:/docker-entrypoint-initdb.d/init.sql
+ ports:
+ - "5432:5432"
+ networks:
+ - agent-network
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+networks:
+ agent-network:
+ driver: bridge
+ name: svrnty-agent-network
+
+volumes:
+ ollama_models:
+ name: svrnty-ollama-models
+ postgres_data:
+ name: svrnty-postgres-data
diff --git a/docker/configs/init-db.sql b/docker/configs/init-db.sql
new file mode 100644
index 0000000..3265fd6
--- /dev/null
+++ b/docker/configs/init-db.sql
@@ -0,0 +1,119 @@
+-- Initialize PostgreSQL databases for Svrnty AI Agent system
+-- This script runs automatically when the PostgreSQL container starts for the first time
+
+-- Create databases
+CREATE DATABASE svrnty;
+CREATE DATABASE langfuse;
+
+-- Connect to svrnty database
+\c svrnty;
+
+-- Create schema for agent data
+CREATE SCHEMA IF NOT EXISTS agent;
+
+-- Conversations table for AI agent conversation history
+CREATE TABLE IF NOT EXISTS agent.conversations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ messages JSONB NOT NULL DEFAULT '[]'::jsonb,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_conversations_created ON agent.conversations(created_at DESC);
+CREATE INDEX idx_conversations_updated ON agent.conversations(updated_at DESC);
+
+-- Revenue table for business data queries
+CREATE TABLE IF NOT EXISTS agent.revenue (
+ id SERIAL PRIMARY KEY,
+ month VARCHAR(50) NOT NULL,
+ amount DECIMAL(18, 2) NOT NULL,
+ year INTEGER NOT NULL,
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX idx_revenue_month ON agent.revenue(month, year);
+
+-- Customers table for business data queries
+CREATE TABLE IF NOT EXISTS agent.customers (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(200) NOT NULL,
+ email VARCHAR(200),
+ state VARCHAR(100),
+ tier VARCHAR(50),
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_customers_state ON agent.customers(state);
+CREATE INDEX idx_customers_tier ON agent.customers(tier);
+CREATE INDEX idx_customers_state_tier ON agent.customers(state, tier);
+
+-- Seed revenue data (2024-2025)
+INSERT INTO agent.revenue (month, amount, year) VALUES
+ ('January', 125000.00, 2024),
+ ('February', 135000.00, 2024),
+ ('March', 148000.00, 2024),
+ ('April', 142000.00, 2024),
+ ('May', 155000.00, 2024),
+ ('June', 168000.00, 2024),
+ ('July', 172000.00, 2024),
+ ('August', 165000.00, 2024),
+ ('September', 178000.00, 2024),
+ ('October', 185000.00, 2024),
+ ('November', 192000.00, 2024),
+ ('December', 210000.00, 2024),
+ ('January', 215000.00, 2025),
+ ('February', 225000.00, 2025),
+ ('March', 235000.00, 2025),
+ ('April', 242000.00, 2025),
+ ('May', 255000.00, 2025)
+ON CONFLICT (month, year) DO NOTHING;
+
+-- Seed customer data
+INSERT INTO agent.customers (name, email, state, tier) VALUES
+ ('Acme Corporation', 'contact@acme.com', 'California', 'Enterprise'),
+ ('TechStart Inc', 'hello@techstart.io', 'New York', 'Professional'),
+ ('Global Solutions LLC', 'info@globalsol.com', 'Texas', 'Enterprise'),
+ ('Innovation Labs', 'team@innovlabs.com', 'California', 'Professional'),
+ ('Digital Dynamics', 'sales@digitaldyn.com', 'Washington', 'Starter'),
+ ('CloudFirst Co', 'contact@cloudfirst.io', 'New York', 'Enterprise'),
+ ('Data Insights Group', 'info@datainsights.com', 'Texas', 'Professional'),
+ ('AI Ventures', 'hello@aiventures.ai', 'California', 'Enterprise'),
+ ('Smart Systems Inc', 'contact@smartsys.com', 'Florida', 'Starter'),
+ ('Future Tech Partners', 'team@futuretech.com', 'Massachusetts', 'Professional'),
+ ('Quantum Analytics', 'info@quantumdata.io', 'New York', 'Enterprise'),
+ ('Rapid Scale Solutions', 'sales@rapidscale.com', 'California', 'Professional'),
+ ('Enterprise Connect', 'hello@entconnect.com', 'Texas', 'Enterprise'),
+ ('Startup Accelerator', 'team@startacc.io', 'Washington', 'Starter'),
+ ('Cloud Native Labs', 'contact@cloudnative.dev', 'Oregon', 'Professional')
+ON CONFLICT DO NOTHING;
+
+-- Create updated_at trigger function
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+-- Add trigger to conversations table
+CREATE TRIGGER update_conversations_updated_at
+ BEFORE UPDATE ON agent.conversations
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Grant permissions (for application user)
+GRANT USAGE ON SCHEMA agent TO postgres;
+GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA agent TO postgres;
+GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA agent TO postgres;
+
+-- Summary
+DO $$
+BEGIN
+ RAISE NOTICE 'Database initialization complete!';
+ RAISE NOTICE '- Created svrnty database with agent schema';
+ RAISE NOTICE '- Created conversations table for AI agent history';
+ RAISE NOTICE '- Created revenue table with % rows', (SELECT COUNT(*) FROM agent.revenue);
+ RAISE NOTICE '- Created customers table with % rows', (SELECT COUNT(*) FROM agent.customers);
+ RAISE NOTICE '- Created langfuse database (will be initialized by Langfuse container)';
+END $$;