Compare commits
No commits in common. "cc2992c74bbf8456deeb36c5ebf0f0f0505284e3" and "24a08c314a9b75cc3193c7131df6392f5e620b17" have entirely different histories.
cc2992c74b
...
24a08c314a
File diff suppressed because one or more lines are too long
@ -1,50 +0,0 @@
|
|||||||
# 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 (commented out - needed for build)
|
|
||||||
docs/
|
|
||||||
.github/
|
|
||||||
|
|
||||||
# Rider
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Scripts (not needed in container)
|
|
||||||
scripts/
|
|
||||||
|
|
||||||
# Docker configs (not needed in container)
|
|
||||||
docker/
|
|
||||||
30
.env
30
.env
@ -1,30 +0,0 @@
|
|||||||
# 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
|
|
||||||
32
.env.example
32
.env.example
@ -1,32 +0,0 @@
|
|||||||
# 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)
|
|
||||||
# Generate these using: openssl rand -base64 32
|
|
||||||
NEXTAUTH_SECRET=REPLACE_WITH_RANDOM_SECRET
|
|
||||||
SALT=REPLACE_WITH_RANDOM_SALT
|
|
||||||
# Generate this using: openssl rand -hex 32
|
|
||||||
ENCRYPTION_KEY=REPLACE_WITH_RANDOM_ENCRYPTION_KEY
|
|
||||||
|
|
||||||
# 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
|
|
||||||
@ -25,7 +25,7 @@ jobs:
|
|||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@v3
|
uses: actions/setup-dotnet@v3
|
||||||
with:
|
with:
|
||||||
dotnet-version: 10.x
|
dotnet-version: 8.x
|
||||||
|
|
||||||
- name: Restore dependencies
|
- name: Restore dependencies
|
||||||
run: dotnet restore
|
run: dotnet restore
|
||||||
|
|||||||
38
.github/workflows/publish-nugets.yml
vendored
Normal file
38
.github/workflows/publish-nugets.yml
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# This workflow will build a .NET project
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||||
|
|
||||||
|
name: Publish NuGets
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- published
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Extract Release Version
|
||||||
|
id: extract_version
|
||||||
|
run: echo "RELEASE_VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Debug Release Version
|
||||||
|
run: echo "RELEASE_VERSION=${{ env.RELEASE_VERSION }}"
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v3
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.x
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build and Pack NuGet Package
|
||||||
|
run: dotnet pack -c Release -o ./artifacts -p:Version=${{ env.RELEASE_VERSION }}
|
||||||
|
|
||||||
|
- name: Publish to NuGet.org
|
||||||
|
run: |
|
||||||
|
dotnet nuget push ./artifacts/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -5,9 +5,6 @@
|
|||||||
|
|
||||||
.research/
|
.research/
|
||||||
|
|
||||||
# Environment variables with secrets
|
|
||||||
.env
|
|
||||||
|
|
||||||
# User-specific files
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.suo
|
||||||
|
|||||||
51
Dockerfile
51
Dockerfile
@ -1,51 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
205
README.md
205
README.md
@ -3,6 +3,7 @@
|
|||||||
# CQRS
|
# CQRS
|
||||||
|
|
||||||
Our implementation of query and command responsibility segregation (CQRS).
|
Our implementation of query and command responsibility segregation (CQRS).
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
> Install nuget package to your awesome project.
|
> Install nuget package to your awesome project.
|
||||||
@ -11,9 +12,10 @@ Our implementation of query and command responsibility segregation (CQRS).
|
|||||||
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
|-----------------------------------------| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |-----------------------------------------------------------------------:|
|
||||||
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
| Svrnty.CQRS | [](https://www.nuget.org/packages/Svrnty.CQRS/) | ```dotnet add package Svrnty.CQRS ``` |
|
||||||
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
| Svrnty.CQRS.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.MinimalApi/) | ```dotnet add package Svrnty.CQRS.MinimalApi ``` |
|
||||||
|
| Svrnty.CQRS.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.AspNetCore/) | ```dotnet add package Svrnty.CQRS.AspNetCore ``` |
|
||||||
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
| Svrnty.CQRS.FluentValidation | [](https://www.nuget.org/packages/Svrnty.CQRS.FluentValidation/) | ```dotnet add package Svrnty.CQRS.FluentValidation ``` |
|
||||||
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
| Svrnty.CQRS.DynamicQuery | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery/) | ```dotnet add package Svrnty.CQRS.DynamicQuery ``` |
|
||||||
| Svrnty.CQRS.DynamicQuery.MinimalApi | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.MinimalApi/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.MinimalApi ``` |
|
| Svrnty.CQRS.DynamicQuery.AspNetCore | [](https://www.nuget.org/packages/Svrnty.CQRS.DynamicQuery.AspNetCore/) | ```dotnet add package Svrnty.CQRS.DynamicQuery.AspNetCore ``` |
|
||||||
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
| Svrnty.CQRS.Grpc | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc/) | ```dotnet add package Svrnty.CQRS.Grpc ``` |
|
||||||
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
| Svrnty.CQRS.Grpc.Generators | [](https://www.nuget.org/packages/Svrnty.CQRS.Grpc.Generators/) | ```dotnet add package Svrnty.CQRS.Grpc.Generators ``` |
|
||||||
|
|
||||||
@ -29,33 +31,28 @@ Our implementation of query and command responsibility segregation (CQRS).
|
|||||||
## Sample of startup code for gRPC (Recommended)
|
## Sample of startup code for gRPC (Recommended)
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Svrnty.CQRS;
|
|
||||||
using Svrnty.CQRS.FluentValidation;
|
|
||||||
using Svrnty.CQRS.Grpc;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register your commands with validators
|
// Register CQRS core services
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
builder.Services.AddSvrntyCQRS();
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
builder.Services.AddDefaultCommandDiscovery();
|
||||||
|
builder.Services.AddDefaultQueryDiscovery();
|
||||||
|
|
||||||
// Register your queries
|
// Add your commands and queries
|
||||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
AddQueries(builder.Services);
|
||||||
|
AddCommands(builder.Services);
|
||||||
|
|
||||||
// Configure CQRS with gRPC support
|
// Add gRPC support
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
builder.Services.AddGrpc();
|
||||||
{
|
|
||||||
// Enable gRPC endpoints with reflection
|
|
||||||
cqrs.AddGrpc(grpc =>
|
|
||||||
{
|
|
||||||
grpc.EnableReflection();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Map all configured CQRS endpoints
|
// Map auto-generated gRPC service implementations
|
||||||
app.UseSvrntyCqrs();
|
app.MapGrpcService<CommandServiceImpl>();
|
||||||
|
app.MapGrpcService<QueryServiceImpl>();
|
||||||
|
|
||||||
|
// Enable gRPC reflection for tools like grpcurl
|
||||||
|
app.MapGrpcReflectionService();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -78,9 +75,31 @@ dotnet add package Grpc.StatusProto # For Rich Error Model validation
|
|||||||
dotnet add package Svrnty.CQRS.Grpc.Generators
|
dotnet add package Svrnty.CQRS.Grpc.Generators
|
||||||
```
|
```
|
||||||
|
|
||||||
The source generator is automatically configured as an analyzer when installed via NuGet and will generate both the `.proto` files and gRPC service implementations at compile time.
|
The source generator is automatically configured as an analyzer when installed via NuGet and will generate the gRPC service implementations at compile time.
|
||||||
|
|
||||||
#### 3. Define your C# commands and queries:
|
#### 3. Define your proto files in `Protos/` directory:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
syntax = "proto3";
|
||||||
|
import "google/protobuf/empty.proto";
|
||||||
|
|
||||||
|
service CommandService {
|
||||||
|
rpc AddUser(AddUserCommandRequest) returns (AddUserCommandResponse);
|
||||||
|
rpc RemoveUser(RemoveUserCommandRequest) returns (google.protobuf.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddUserCommandRequest {
|
||||||
|
string name = 1;
|
||||||
|
string email = 2;
|
||||||
|
int32 age = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddUserCommandResponse {
|
||||||
|
int32 result = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Define your C# commands matching the proto structure:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
public record AddUserCommand
|
public record AddUserCommand
|
||||||
@ -97,38 +116,28 @@ public record RemoveUserCommand
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
- The source generator automatically creates:
|
- The source generator automatically creates `CommandServiceImpl` and `QueryServiceImpl` implementations
|
||||||
- `.proto` files in the `Protos/` directory from your C# commands and queries
|
- Property names in C# commands must match proto field names (case-insensitive)
|
||||||
- `CommandServiceImpl` and `QueryServiceImpl` implementations
|
|
||||||
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
- FluentValidation is automatically integrated with **Google Rich Error Model** for structured validation errors
|
||||||
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
- Validation errors return `google.rpc.Status` with `BadRequest` containing `FieldViolations`
|
||||||
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
- Use `record` types for commands/queries (immutable, value-based equality, more concise)
|
||||||
- No need for protobuf-net attributes - just define your C# types
|
- No need for protobuf-net attributes
|
||||||
|
|
||||||
## Sample of startup code for Minimal API (HTTP)
|
## Sample of startup code for Minimal API (HTTP)
|
||||||
|
|
||||||
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
For HTTP scenarios (web browsers, public APIs), you can use the Minimal API approach:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Svrnty.CQRS;
|
|
||||||
using Svrnty.CQRS.FluentValidation;
|
|
||||||
using Svrnty.CQRS.MinimalApi;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register your commands with validators
|
// Register CQRS core services
|
||||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
builder.Services.AddSvrntyCQRS();
|
||||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
builder.Services.AddDefaultCommandDiscovery();
|
||||||
|
builder.Services.AddDefaultQueryDiscovery();
|
||||||
|
|
||||||
// Register your queries
|
// Add your commands and queries
|
||||||
builder.Services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
AddQueries(builder.Services);
|
||||||
|
AddCommands(builder.Services);
|
||||||
// Configure CQRS with Minimal API support
|
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
|
||||||
{
|
|
||||||
// Enable Minimal API endpoints
|
|
||||||
cqrs.AddMinimalApi();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Swagger (optional)
|
// Add Swagger (optional)
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@ -142,8 +151,9 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map all configured CQRS endpoints (automatically creates POST /api/command/* and POST/GET /api/query/*)
|
// Map CQRS endpoints - automatically creates routes for all commands and queries
|
||||||
app.UseSvrntyCqrs();
|
app.MapSvrntyCommands(); // Creates POST /api/command/{commandName} endpoints
|
||||||
|
app.MapSvrntyQueries(); // Creates POST/GET /api/query/{queryName} endpoints
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -159,32 +169,19 @@ app.Run();
|
|||||||
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
You can enable both gRPC and traditional HTTP endpoints simultaneously, allowing clients to choose their preferred protocol:
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using Svrnty.CQRS;
|
|
||||||
using Svrnty.CQRS.FluentValidation;
|
|
||||||
using Svrnty.CQRS.Grpc;
|
|
||||||
using Svrnty.CQRS.MinimalApi;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Register your commands with validators
|
// Register CQRS core services
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
builder.Services.AddSvrntyCQRS();
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
builder.Services.AddDefaultCommandDiscovery();
|
||||||
|
builder.Services.AddDefaultQueryDiscovery();
|
||||||
|
|
||||||
// Register your queries
|
// Add your commands and queries
|
||||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
AddQueries(builder.Services);
|
||||||
|
AddCommands(builder.Services);
|
||||||
|
|
||||||
// Configure CQRS with both gRPC and Minimal API support
|
// Add gRPC support
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
builder.Services.AddGrpc();
|
||||||
{
|
|
||||||
// Enable gRPC endpoints with reflection
|
|
||||||
cqrs.AddGrpc(grpc =>
|
|
||||||
{
|
|
||||||
grpc.EnableReflection();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Enable Minimal API endpoints
|
|
||||||
cqrs.AddMinimalApi();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add HTTP support with Swagger
|
// Add HTTP support with Swagger
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
@ -198,8 +195,14 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map all configured CQRS endpoints (both gRPC and HTTP)
|
// Map gRPC endpoints
|
||||||
app.UseSvrntyCqrs();
|
app.MapGrpcService<CommandServiceImpl>();
|
||||||
|
app.MapGrpcService<QueryServiceImpl>();
|
||||||
|
app.MapGrpcReflectionService();
|
||||||
|
|
||||||
|
// Map HTTP endpoints
|
||||||
|
app.MapSvrntyCommands();
|
||||||
|
app.MapSvrntyQueries();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
```
|
```
|
||||||
@ -211,10 +214,47 @@ app.Run();
|
|||||||
- Same commands, queries, and validation logic for both protocols
|
- Same commands, queries, and validation logic for both protocols
|
||||||
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
- Swagger UI available for HTTP endpoints, gRPC reflection for gRPC clients
|
||||||
|
|
||||||
|
> Example how to add your queries and commands.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void AddCommands(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler>();
|
||||||
|
services.AddTransient<IValidator<CreatePersonCommand>, CreatePersonCommandValidator>();
|
||||||
|
|
||||||
|
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||||
|
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddQueries(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddQuery<PersonQuery, IQueryable<Person>, PersonQueryHandler>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
# Fluent Validation
|
# Fluent Validation
|
||||||
|
|
||||||
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
FluentValidation is optional but recommended for command and query validation. The `Svrnty.CQRS.FluentValidation` package provides extension methods to simplify validator registration.
|
||||||
|
|
||||||
|
## Without Svrnty.CQRS.FluentValidation
|
||||||
|
|
||||||
|
You need to register commands and validators separately:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using FluentValidation;
|
||||||
|
using Svrnty.CQRS;
|
||||||
|
|
||||||
|
private void AddCommands(IServiceCollection services)
|
||||||
|
{
|
||||||
|
// Register command handler
|
||||||
|
services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
||||||
|
|
||||||
|
// Manually register validator
|
||||||
|
services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## With Svrnty.CQRS.FluentValidation (Recommended)
|
## With Svrnty.CQRS.FluentValidation (Recommended)
|
||||||
|
|
||||||
The package exposes extension method overloads that accept the validator as a generic parameter:
|
The package exposes extension method overloads that accept the validator as a generic parameter:
|
||||||
@ -224,13 +264,17 @@ dotnet add package Svrnty.CQRS.FluentValidation
|
|||||||
```
|
```
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
using Svrnty.CQRS.FluentValidation; // Extension methods for validator registration
|
||||||
|
|
||||||
// Command with result - validator as last generic parameter
|
private void AddCommands(IServiceCollection services)
|
||||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
{
|
||||||
|
|
||||||
// Command without result - validator included in generics
|
// Command without result - validator included in generics
|
||||||
builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
services.AddCommand<EchoCommand, string, EchoCommandHandler, EchoCommandValidator>();
|
||||||
|
|
||||||
|
// Command with result - validator as last generic parameter
|
||||||
|
services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, CreatePersonCommandValidator>();
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
@ -239,21 +283,6 @@ builder.Services.AddCommand<CreatePersonCommand, CreatePersonCommandHandler, Cre
|
|||||||
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
- **Less boilerplate** - No need for separate `AddTransient<IValidator<T>>()` calls
|
||||||
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
- **Cleaner code** - Clear intent that validation is part of command pipeline
|
||||||
|
|
||||||
## Without Svrnty.CQRS.FluentValidation
|
|
||||||
|
|
||||||
If you prefer not to use the FluentValidation package, you need to register commands and validators separately:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using FluentValidation;
|
|
||||||
using Svrnty.CQRS;
|
|
||||||
|
|
||||||
// Register command handler
|
|
||||||
builder.Services.AddCommand<EchoCommand, string, EchoCommandHandler>();
|
|
||||||
|
|
||||||
// Manually register validator
|
|
||||||
builder.Services.AddTransient<IValidator<EchoCommand>, EchoCommandValidator>();
|
|
||||||
```
|
|
||||||
|
|
||||||
# 2024-2025 Roadmap
|
# 2024-2025 Roadmap
|
||||||
|
|
||||||
| Task | Description | Status |
|
| Task | Description | Status |
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
|
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
|
||||||
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">true</IsAotCompatible>
|
<IsAotCompatible Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">true</IsAotCompatible>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
|
<Authors>David Lebee, Mathias Beaulieu-Duncan</Authors>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>false</IsAotCompatible>
|
<IsAotCompatible>false</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -1,16 +1,13 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.0</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsRoslynComponent>true</IsRoslynComponent>
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
<IsPackable>true</IsPackable>
|
<IsPackable>true</IsPackable>
|
||||||
<DevelopmentDependency>true</DevelopmentDependency>
|
<DevelopmentDependency>true</DevelopmentDependency>
|
||||||
<!-- Don't include build output in lib/ - this is an analyzer/generator package -->
|
|
||||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
<NoPackageAnalysis>true</NoPackageAnalysis>
|
|
||||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
|
||||||
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
@ -23,8 +20,11 @@
|
|||||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
<Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description>
|
<Description>Source Generator for Svrnty.CQRS.Grpc - generates .proto files and gRPC service implementations from commands and queries</Description>
|
||||||
|
|
||||||
<!-- Disable symbol packages for analyzer/generator packages (prevents NU5017 error) -->
|
<DebugType>portable</DebugType>
|
||||||
<IncludeSymbols>false</IncludeSymbols>
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
<IncludeSymbols>true</IncludeSymbols>
|
||||||
|
<IncludeSource>true</IncludeSource>
|
||||||
|
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@ -39,24 +39,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Include targets and props files in both build and buildTransitive for proper dependency flow -->
|
<!-- Package as analyzer -->
|
||||||
|
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
|
||||||
|
|
||||||
|
<!-- Also package as build task -->
|
||||||
|
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="build" Visible="false" />
|
||||||
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" />
|
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="build" />
|
||||||
<None Include="build\Svrnty.CQRS.Grpc.Generators.targets" Pack="true" PackagePath="buildTransitive" />
|
|
||||||
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="build" />
|
|
||||||
<None Include="build\Svrnty.CQRS.Grpc.Generators.props" Pack="true" PackagePath="buildTransitive" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Use the recommended pattern to include the generator DLL in the package -->
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);IncludeGeneratorAssemblyInPackage</TargetsForTfmSpecificContentInPackage>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<Target Name="IncludeGeneratorAssemblyInPackage">
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- Include in analyzers folder for Roslyn source generator -->
|
|
||||||
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="analyzers/dotnet/cs" />
|
|
||||||
<!-- Include in build folder for MSBuild task (WriteProtoFileTask) -->
|
|
||||||
<TfmSpecificPackageFile Include="$(OutputPath)$(AssemblyName).dll" PackagePath="build" />
|
|
||||||
</ItemGroup>
|
|
||||||
</Target>
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
<Project>
|
|
||||||
<PropertyGroup>
|
|
||||||
<!-- Marker to indicate Svrnty.CQRS.Grpc.Generators is referenced -->
|
|
||||||
<SvrntyCqrsGrpcGeneratorsVersion>$(SvrntyCqrsGrpcGeneratorsVersion)</SvrntyCqrsGrpcGeneratorsVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
</Project>
|
|
||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>false</IsAotCompatible>
|
<IsAotCompatible>false</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>false</IsAotCompatible>
|
<IsAotCompatible>false</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<IsAotCompatible>true</IsAotCompatible>
|
<IsAotCompatible>true</IsAotCompatible>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>14</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
<Company>Svrnty</Company>
|
<Company>Svrnty</Company>
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
using Svrnty.CQRS.Grpc.Abstractions.Attributes;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.AI.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to execute an AI agent with a user prompt
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Prompt">The user's input prompt for the AI agent</param>
|
|
||||||
[GrpcIgnore] // MVP: HTTP-only endpoint, gRPC support can be added later
|
|
||||||
public record ExecuteAgentCommand(string Prompt);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Response from the AI agent execution
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="Content">The AI agent's response content</param>
|
|
||||||
/// <param name="ConversationId">Unique identifier for this conversation</param>
|
|
||||||
public record AgentResponse(string Content, Guid ConversationId);
|
|
||||||
@ -1,245 +0,0 @@
|
|||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for executing AI agent commands with function calling support and Langfuse HTTP observability
|
|
||||||
/// </summary>
|
|
||||||
public class ExecuteAgentCommandHandler(
|
|
||||||
IChatClient chatClient,
|
|
||||||
AgentDbContext dbContext,
|
|
||||||
MathTool mathTool,
|
|
||||||
DatabaseQueryTool dbTool,
|
|
||||||
ILogger<ExecuteAgentCommandHandler> logger,
|
|
||||||
LangfuseHttpClient langfuseClient) : ICommandHandler<ExecuteAgentCommand, AgentResponse>
|
|
||||||
{
|
|
||||||
private const int MaxFunctionCallIterations = 10; // Prevent infinite loops
|
|
||||||
|
|
||||||
public async Task<AgentResponse> HandleAsync(
|
|
||||||
ExecuteAgentCommand command,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var conversationId = Guid.NewGuid();
|
|
||||||
|
|
||||||
// Start Langfuse trace (if enabled)
|
|
||||||
LangfuseTrace? trace = null;
|
|
||||||
if (langfuseClient.IsEnabled)
|
|
||||||
{
|
|
||||||
trace = await langfuseClient.CreateTraceAsync("agent-execution", "system");
|
|
||||||
trace.SetInput(command.Prompt);
|
|
||||||
trace.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["conversation_id"] = conversationId.ToString(),
|
|
||||||
["model"] = "qwen2.5-coder:7b"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var messages = new List<ChatMessage>
|
|
||||||
{
|
|
||||||
new(ChatRole.User, command.Prompt)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register available tools
|
|
||||||
var tools = new List<AIFunction>
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log tool registration to Langfuse
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
using var toolSpan = trace.CreateSpan("tools-register");
|
|
||||||
toolSpan.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["tools_count"] = tools.Count,
|
|
||||||
["tools_names"] = string.Join(",", tools.Select(t => t.Metadata.Name))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var options = new ChatOptions
|
|
||||||
{
|
|
||||||
ModelId = "qwen2.5-coder:7b",
|
|
||||||
Tools = tools.Cast<AITool>().ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
var functionLookup = tools.ToDictionary(
|
|
||||||
f => f.Metadata.Name,
|
|
||||||
f => f,
|
|
||||||
StringComparer.OrdinalIgnoreCase
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initial AI completion
|
|
||||||
ChatCompletion completion;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
using var generation = trace.CreateGeneration("llm-completion-0");
|
|
||||||
generation.SetInput(command.Prompt);
|
|
||||||
completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
|
|
||||||
messages.Add(completion.Message);
|
|
||||||
generation.SetOutput(completion.Message.Text ?? "");
|
|
||||||
generation.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["iteration"] = 0,
|
|
||||||
["has_function_calls"] = completion.Message.Contents.OfType<FunctionCallContent>().Any()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
|
|
||||||
messages.Add(completion.Message);
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
// Function calling loop
|
|
||||||
var iterations = 0;
|
|
||||||
while (completion.Message.Contents.OfType<FunctionCallContent>().Any()
|
|
||||||
&& iterations < MaxFunctionCallIterations)
|
|
||||||
{
|
|
||||||
iterations++;
|
|
||||||
|
|
||||||
foreach (var functionCall in completion.Message.Contents.OfType<FunctionCallContent>())
|
|
||||||
{
|
|
||||||
object? funcResult = null;
|
|
||||||
string? funcError = null;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (!functionLookup.TryGetValue(functionCall.Name, out var function))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Function '{functionCall.Name}' not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
funcResult = await function.InvokeAsync(functionCall.Arguments, cancellationToken);
|
|
||||||
|
|
||||||
var toolMessage = new ChatMessage(ChatRole.Tool, funcResult?.ToString() ?? "null");
|
|
||||||
toolMessage.Contents.Add(new FunctionResultContent(functionCall.CallId, functionCall.Name, funcResult));
|
|
||||||
messages.Add(toolMessage);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
funcError = 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log function call to Langfuse
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
using var funcSpan = trace.CreateSpan($"function-{functionCall.Name}");
|
|
||||||
funcSpan.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["function_name"] = functionCall.Name,
|
|
||||||
["arguments"] = JsonSerializer.Serialize(functionCall.Arguments),
|
|
||||||
["result"] = funcResult?.ToString() ?? "null",
|
|
||||||
["success"] = funcError == null,
|
|
||||||
["error"] = funcError ?? ""
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next LLM completion after function calls
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
using var nextGeneration = trace.CreateGeneration($"llm-completion-{iterations}");
|
|
||||||
nextGeneration.SetInput(JsonSerializer.Serialize(messages.TakeLast(5)));
|
|
||||||
completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
|
|
||||||
messages.Add(completion.Message);
|
|
||||||
nextGeneration.SetOutput(completion.Message.Text ?? "");
|
|
||||||
nextGeneration.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["iteration"] = iterations,
|
|
||||||
["has_function_calls"] = completion.Message.Contents.OfType<FunctionCallContent>().Any()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
completion = await chatClient.CompleteAsync(messages, options, cancellationToken);
|
|
||||||
messages.Add(completion.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
};
|
|
||||||
|
|
||||||
dbContext.Conversations.Add(conversation);
|
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
// Update trace with final output and flush to Langfuse
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
trace.SetOutput(completion.Message.Text ?? "No response");
|
|
||||||
trace.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["success"] = true,
|
|
||||||
["iterations"] = iterations,
|
|
||||||
["conversation_id"] = conversationId.ToString()
|
|
||||||
});
|
|
||||||
await trace.FlushAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Agent executed successfully for conversation {ConversationId}", conversationId);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
return new AgentResponse(
|
|
||||||
Content: completion.Message.Text ?? "No response",
|
|
||||||
ConversationId: conversationId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
// Update trace with error and flush to Langfuse
|
|
||||||
if (trace != null)
|
|
||||||
{
|
|
||||||
trace.SetOutput($"Error: {ex.Message}");
|
|
||||||
trace.SetMetadata(new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["success"] = false,
|
|
||||||
["error_type"] = ex.GetType().Name,
|
|
||||||
["error_message"] = ex.Message
|
|
||||||
});
|
|
||||||
await trace.FlushAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogError(ex, "Agent execution failed for conversation {ConversationId}", conversationId);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,336 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.AI;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Simple HTTP client for sending traces directly to Langfuse ingestion API
|
|
||||||
/// </summary>
|
|
||||||
public class LangfuseHttpClient
|
|
||||||
{
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly string _publicKey;
|
|
||||||
private readonly string _secretKey;
|
|
||||||
private readonly bool _enabled;
|
|
||||||
|
|
||||||
public LangfuseHttpClient(HttpClient httpClient, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
_httpClient = httpClient;
|
|
||||||
_publicKey = configuration["Langfuse:PublicKey"] ?? "";
|
|
||||||
_secretKey = configuration["Langfuse:SecretKey"] ?? "";
|
|
||||||
_enabled = !string.IsNullOrEmpty(_publicKey) && !string.IsNullOrEmpty(_secretKey);
|
|
||||||
|
|
||||||
_ = Console.Out.WriteLineAsync($"[Langfuse] Initialized: Enabled={_enabled}, PublicKey={(_publicKey.Length > 0 ? "present" : "missing")}, SecretKey={(_secretKey.Length > 0 ? "present" : "missing")}");
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsEnabled => _enabled;
|
|
||||||
|
|
||||||
public async Task<LangfuseTrace> CreateTraceAsync(string name, string userId = "system")
|
|
||||||
{
|
|
||||||
return new LangfuseTrace(this, name, userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal async Task SendBatchAsync(List<LangfuseEvent> events)
|
|
||||||
{
|
|
||||||
// File-based debug logging
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.AppendAllTextAsync("/tmp/langfuse_debug.log",
|
|
||||||
$"{DateTime.UtcNow:O} [SendBatchAsync] Called: Enabled={_enabled}, Events={events.Count}\n");
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
if (!_enabled || events.Count == 0) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var batch = new { batch = events };
|
|
||||||
var json = JsonSerializer.Serialize(batch, new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
||||||
});
|
|
||||||
|
|
||||||
_ = Console.Out.WriteLineAsync($"[Langfuse] Sending {events.Count} events to {_httpClient.BaseAddress}/api/public/ingestion");
|
|
||||||
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, "/api/public/ingestion")
|
|
||||||
{
|
|
||||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
|
||||||
};
|
|
||||||
|
|
||||||
// Basic Auth with public/secret keys
|
|
||||||
var authBytes = Encoding.UTF8.GetBytes($"{_publicKey}:{_secretKey}");
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
|
|
||||||
"Basic", Convert.ToBase64String(authBytes));
|
|
||||||
|
|
||||||
var response = await _httpClient.SendAsync(request);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
_ = Console.Out.WriteLineAsync($"[Langfuse] Successfully sent batch, status: {response.StatusCode}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Log but don't throw - tracing shouldn't break the application
|
|
||||||
_ = Console.Out.WriteLineAsync($"[Langfuse] Failed to send trace: {ex.Message}");
|
|
||||||
_ = Console.Out.WriteLineAsync($"[Langfuse] Stack trace: {ex.StackTrace}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a Langfuse trace that can contain multiple observations
|
|
||||||
/// </summary>
|
|
||||||
public class LangfuseTrace
|
|
||||||
{
|
|
||||||
private readonly LangfuseHttpClient _client;
|
|
||||||
private readonly string _traceId;
|
|
||||||
private readonly List<LangfuseEvent> _events = new();
|
|
||||||
private string? _input;
|
|
||||||
private string? _output;
|
|
||||||
private Dictionary<string, object>? _metadata;
|
|
||||||
|
|
||||||
internal LangfuseTrace(LangfuseHttpClient client, string name, string userId)
|
|
||||||
{
|
|
||||||
_client = client;
|
|
||||||
_traceId = Guid.NewGuid().ToString();
|
|
||||||
|
|
||||||
_events.Add(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = _traceId,
|
|
||||||
Type = "trace-create",
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
Body = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["id"] = _traceId,
|
|
||||||
["name"] = name,
|
|
||||||
["userId"] = userId,
|
|
||||||
["timestamp"] = DateTime.UtcNow
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public string TraceId => _traceId;
|
|
||||||
|
|
||||||
public void SetInput(object input)
|
|
||||||
{
|
|
||||||
_input = input is string s ? s : JsonSerializer.Serialize(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetOutput(object output)
|
|
||||||
{
|
|
||||||
_output = output is string s ? s : JsonSerializer.Serialize(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetMetadata(Dictionary<string, object> metadata)
|
|
||||||
{
|
|
||||||
_metadata = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public LangfuseSpan CreateSpan(string name)
|
|
||||||
{
|
|
||||||
return new LangfuseSpan(this, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
public LangfuseGeneration CreateGeneration(string name, string model = "qwen2.5-coder:7b")
|
|
||||||
{
|
|
||||||
return new LangfuseGeneration(this, name, model);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void AddEvent(LangfuseEvent evt)
|
|
||||||
{
|
|
||||||
_events.Add(evt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task FlushAsync()
|
|
||||||
{
|
|
||||||
// File-based debug logging
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await File.AppendAllTextAsync("/tmp/langfuse_debug.log",
|
|
||||||
$"{DateTime.UtcNow:O} [FlushAsync] Called: Events={_events.Count}, HasInput={_input != null}, HasOutput={_output != null}, Enabled={_client.IsEnabled}\n");
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
|
|
||||||
// Update trace with final input/output
|
|
||||||
if (_input != null || _output != null || _metadata != null)
|
|
||||||
{
|
|
||||||
var updateBody = new Dictionary<string, object> { ["id"] = _traceId };
|
|
||||||
if (_input != null) updateBody["input"] = _input;
|
|
||||||
if (_output != null) updateBody["output"] = _output;
|
|
||||||
if (_metadata != null) updateBody["metadata"] = _metadata;
|
|
||||||
|
|
||||||
_events.Add(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
Type = "trace-create", // Langfuse uses same type for updates
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
Body = updateBody
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await _client.SendBatchAsync(_events);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a span (operation) within a trace
|
|
||||||
/// </summary>
|
|
||||||
public class LangfuseSpan : IDisposable
|
|
||||||
{
|
|
||||||
private readonly LangfuseTrace _trace;
|
|
||||||
private readonly string _spanId;
|
|
||||||
private readonly DateTime _startTime;
|
|
||||||
private object? _output;
|
|
||||||
private Dictionary<string, object>? _metadata;
|
|
||||||
|
|
||||||
internal LangfuseSpan(LangfuseTrace trace, string name)
|
|
||||||
{
|
|
||||||
_trace = trace;
|
|
||||||
_spanId = Guid.NewGuid().ToString();
|
|
||||||
_startTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
_trace.AddEvent(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = _spanId,
|
|
||||||
Type = "span-create",
|
|
||||||
Timestamp = _startTime,
|
|
||||||
Body = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["id"] = _spanId,
|
|
||||||
["traceId"] = trace.TraceId,
|
|
||||||
["name"] = name,
|
|
||||||
["startTime"] = _startTime
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetOutput(object output)
|
|
||||||
{
|
|
||||||
_output = output;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetMetadata(Dictionary<string, object> metadata)
|
|
||||||
{
|
|
||||||
_metadata = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
var updateBody = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["id"] = _spanId,
|
|
||||||
["endTime"] = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_output != null)
|
|
||||||
updateBody["output"] = _output is string s ? s : JsonSerializer.Serialize(_output);
|
|
||||||
|
|
||||||
if (_metadata != null)
|
|
||||||
updateBody["metadata"] = _metadata;
|
|
||||||
|
|
||||||
_trace.AddEvent(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
Type = "span-update",
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
Body = updateBody
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an LLM generation within a trace
|
|
||||||
/// </summary>
|
|
||||||
public class LangfuseGeneration : IDisposable
|
|
||||||
{
|
|
||||||
private readonly LangfuseTrace _trace;
|
|
||||||
private readonly string _generationId;
|
|
||||||
private readonly DateTime _startTime;
|
|
||||||
private readonly string _model;
|
|
||||||
private object? _input;
|
|
||||||
private object? _output;
|
|
||||||
private Dictionary<string, object>? _metadata;
|
|
||||||
|
|
||||||
internal LangfuseGeneration(LangfuseTrace trace, string name, string model)
|
|
||||||
{
|
|
||||||
_trace = trace;
|
|
||||||
_generationId = Guid.NewGuid().ToString();
|
|
||||||
_startTime = DateTime.UtcNow;
|
|
||||||
_model = model;
|
|
||||||
|
|
||||||
_trace.AddEvent(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = _generationId,
|
|
||||||
Type = "generation-create",
|
|
||||||
Timestamp = _startTime,
|
|
||||||
Body = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["id"] = _generationId,
|
|
||||||
["traceId"] = trace.TraceId,
|
|
||||||
["name"] = name,
|
|
||||||
["model"] = model,
|
|
||||||
["startTime"] = _startTime
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetInput(object input)
|
|
||||||
{
|
|
||||||
_input = input;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetOutput(object output)
|
|
||||||
{
|
|
||||||
_output = output;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetMetadata(Dictionary<string, object> metadata)
|
|
||||||
{
|
|
||||||
_metadata = metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
var updateBody = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["id"] = _generationId,
|
|
||||||
["endTime"] = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
if (_input != null)
|
|
||||||
updateBody["input"] = _input is string s ? s : JsonSerializer.Serialize(_input);
|
|
||||||
|
|
||||||
if (_output != null)
|
|
||||||
updateBody["output"] = _output is string o ? o : JsonSerializer.Serialize(_output);
|
|
||||||
|
|
||||||
if (_metadata != null)
|
|
||||||
updateBody["metadata"] = _metadata;
|
|
||||||
|
|
||||||
_trace.AddEvent(new LangfuseEvent
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
Type = "generation-update",
|
|
||||||
Timestamp = DateTime.UtcNow,
|
|
||||||
Body = updateBody
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Internal event format for Langfuse ingestion API
|
|
||||||
/// </summary>
|
|
||||||
internal class LangfuseEvent
|
|
||||||
{
|
|
||||||
[JsonPropertyName("id")]
|
|
||||||
public string Id { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("type")]
|
|
||||||
public string Type { get; set; } = "";
|
|
||||||
|
|
||||||
[JsonPropertyName("timestamp")]
|
|
||||||
public DateTime Timestamp { get; set; }
|
|
||||||
|
|
||||||
[JsonPropertyName("body")]
|
|
||||||
public Dictionary<string, object> Body { get; set; } = new();
|
|
||||||
}
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
using System.Diagnostics;
|
|
||||||
using Microsoft.Extensions.AI;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
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<ChatCompletion> CompleteAsync(
|
|
||||||
IList<ChatMessage> messages,
|
|
||||||
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
|
|
||||||
{
|
|
||||||
role = m.Role.ToString().ToLower(),
|
|
||||||
content = m.Text ?? string.Empty,
|
|
||||||
tool_call_id = m.Contents.OfType<FunctionResultContent>().FirstOrDefault()?.CallId
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
// Build payload with optional tools
|
|
||||||
var payload = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["model"] = options?.ModelId ?? "qwen2.5-coder:7b",
|
|
||||||
["messages"] = ollamaMessages,
|
|
||||||
["stream"] = false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add tools if provided
|
|
||||||
if (options?.Tools is { Count: > 0 })
|
|
||||||
{
|
|
||||||
payload["tools"] = options.Tools.Select(BuildToolDefinition).ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await http.PostAsJsonAsync("/api/chat", payload, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var json = await response.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken);
|
|
||||||
var messageElement = json!.RootElement.GetProperty("message");
|
|
||||||
|
|
||||||
var content = messageElement.TryGetProperty("content", out var contentProp)
|
|
||||||
? contentProp.GetString() ?? ""
|
|
||||||
: "";
|
|
||||||
|
|
||||||
var chatMessage = new ChatMessage(ChatRole.Assistant, content);
|
|
||||||
|
|
||||||
// Parse tool calls - handle both OpenAI format and text-based format
|
|
||||||
if (messageElement.TryGetProperty("tool_calls", out var toolCallsElement))
|
|
||||||
{
|
|
||||||
// OpenAI-style tool_calls format
|
|
||||||
foreach (var toolCall in toolCallsElement.EnumerateArray())
|
|
||||||
{
|
|
||||||
var function = toolCall.GetProperty("function");
|
|
||||||
var functionName = function.GetProperty("name").GetString()!;
|
|
||||||
var argumentsJson = function.GetProperty("arguments");
|
|
||||||
|
|
||||||
var arguments = ParseArguments(argumentsJson);
|
|
||||||
|
|
||||||
chatMessage.Contents.Add(new FunctionCallContent(
|
|
||||||
callId: Guid.NewGuid().ToString(),
|
|
||||||
name: functionName,
|
|
||||||
arguments: arguments
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(content) && content.TrimStart().StartsWith("{"))
|
|
||||||
{
|
|
||||||
// Text-based function call format (some models like qwen2.5-coder return this)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var functionCallJson = JsonDocument.Parse(content);
|
|
||||||
if (functionCallJson.RootElement.TryGetProperty("name", out var nameProp) &&
|
|
||||||
functionCallJson.RootElement.TryGetProperty("arguments", out var argsProp))
|
|
||||||
{
|
|
||||||
var functionName = nameProp.GetString()!;
|
|
||||||
var arguments = ParseArguments(argsProp);
|
|
||||||
|
|
||||||
chatMessage.Contents.Add(new FunctionCallContent(
|
|
||||||
callId: Guid.NewGuid().ToString(),
|
|
||||||
name: functionName,
|
|
||||||
arguments: arguments
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Not a function call, just regular content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<FunctionCallContent>().Any());
|
|
||||||
|
|
||||||
return new ChatCompletion(chatMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, object?> ParseArguments(JsonElement argumentsJson)
|
|
||||||
{
|
|
||||||
var arguments = new Dictionary<string, object?>();
|
|
||||||
foreach (var prop in argumentsJson.EnumerateObject())
|
|
||||||
{
|
|
||||||
arguments[prop.Name] = prop.Value.ValueKind switch
|
|
||||||
{
|
|
||||||
JsonValueKind.Number => prop.Value.GetDouble(),
|
|
||||||
JsonValueKind.String => prop.Value.GetString(),
|
|
||||||
JsonValueKind.True => true,
|
|
||||||
JsonValueKind.False => false,
|
|
||||||
_ => prop.Value.ToString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return arguments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object BuildToolDefinition(AITool tool)
|
|
||||||
{
|
|
||||||
var functionInfo = tool.GetType().GetProperty("Metadata")?.GetValue(tool) as AIFunctionMetadata
|
|
||||||
?? throw new InvalidOperationException("Tool must have Metadata property");
|
|
||||||
|
|
||||||
var parameters = new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["type"] = "object",
|
|
||||||
["properties"] = functionInfo.Parameters.ToDictionary(
|
|
||||||
p => p.Name,
|
|
||||||
p => new Dictionary<string, object>
|
|
||||||
{
|
|
||||||
["type"] = GetJsonType(p.ParameterType),
|
|
||||||
["description"] = p.Description ?? ""
|
|
||||||
}
|
|
||||||
),
|
|
||||||
["required"] = functionInfo.Parameters
|
|
||||||
.Where(p => p.IsRequired)
|
|
||||||
.Select(p => p.Name)
|
|
||||||
.ToArray()
|
|
||||||
};
|
|
||||||
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
type = "function",
|
|
||||||
function = new
|
|
||||||
{
|
|
||||||
name = functionInfo.Name,
|
|
||||||
description = functionInfo.Description ?? "",
|
|
||||||
parameters
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetJsonType(Type type)
|
|
||||||
{
|
|
||||||
if (type == typeof(int) || type == typeof(long) || type == typeof(short))
|
|
||||||
return "integer";
|
|
||||||
if (type == typeof(float) || type == typeof(double) || type == typeof(decimal))
|
|
||||||
return "number";
|
|
||||||
if (type == typeof(bool))
|
|
||||||
return "boolean";
|
|
||||||
if (type == typeof(string))
|
|
||||||
return "string";
|
|
||||||
return "object";
|
|
||||||
}
|
|
||||||
|
|
||||||
public IAsyncEnumerable<StreamingChatCompletionUpdate> CompleteStreamingAsync(
|
|
||||||
IList<ChatMessage> messages,
|
|
||||||
ChatOptions? options = null,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
=> throw new NotImplementedException("Streaming not supported in MVP");
|
|
||||||
|
|
||||||
public TService? GetService<TService>(object? key = null) where TService : class
|
|
||||||
=> this as TService;
|
|
||||||
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.AI.Tools;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Business tool for querying database and business metrics
|
|
||||||
/// </summary>
|
|
||||||
public class DatabaseQueryTool
|
|
||||||
{
|
|
||||||
// Simulated data - replace with actual database queries via CQRS
|
|
||||||
private static readonly Dictionary<string, decimal> MonthlyRevenue = new()
|
|
||||||
{
|
|
||||||
["2025-01"] = 50000m,
|
|
||||||
["2025-02"] = 45000m,
|
|
||||||
["2025-03"] = 55000m,
|
|
||||||
["2025-04"] = 62000m,
|
|
||||||
["2025-05"] = 58000m,
|
|
||||||
["2025-06"] = 67000m
|
|
||||||
};
|
|
||||||
|
|
||||||
private static readonly List<(string Name, string State, string Tier)> Customers = new()
|
|
||||||
{
|
|
||||||
("Acme Corp", "California", "Enterprise"),
|
|
||||||
("TechStart Inc", "California", "Startup"),
|
|
||||||
("BigRetail LLC", "Texas", "Enterprise"),
|
|
||||||
("SmallShop", "New York", "SMB"),
|
|
||||||
("MegaCorp", "California", "Enterprise")
|
|
||||||
};
|
|
||||||
|
|
||||||
[Description("Get revenue for a specific month in YYYY-MM format")]
|
|
||||||
public decimal GetMonthlyRevenue(
|
|
||||||
[Description("Month in YYYY-MM format, e.g., 2025-01")] string month)
|
|
||||||
{
|
|
||||||
return MonthlyRevenue.TryGetValue(month, out var revenue) ? revenue : 0m;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Calculate total revenue between two months (inclusive)")]
|
|
||||||
public decimal GetRevenueRange(
|
|
||||||
[Description("Start month in YYYY-MM format")] string startMonth,
|
|
||||||
[Description("End month in YYYY-MM format")] string endMonth)
|
|
||||||
{
|
|
||||||
var total = 0m;
|
|
||||||
foreach (var kvp in MonthlyRevenue)
|
|
||||||
{
|
|
||||||
if (string.Compare(kvp.Key, startMonth, StringComparison.Ordinal) >= 0 &&
|
|
||||||
string.Compare(kvp.Key, endMonth, StringComparison.Ordinal) <= 0)
|
|
||||||
{
|
|
||||||
total += kvp.Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Count customers by state")]
|
|
||||||
public int CountCustomersByState(
|
|
||||||
[Description("US state name, e.g., California")] string state)
|
|
||||||
{
|
|
||||||
return Customers.Count(c => c.State.Equals(state, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Count customers by tier (Enterprise, SMB, Startup)")]
|
|
||||||
public int CountCustomersByTier(
|
|
||||||
[Description("Customer tier: Enterprise, SMB, or Startup")] string tier)
|
|
||||||
{
|
|
||||||
return Customers.Count(c => c.Tier.Equals(tier, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Description("Get list of customer names by state and tier")]
|
|
||||||
public string GetCustomers(
|
|
||||||
[Description("US state name, optional")] string? state = null,
|
|
||||||
[Description("Customer tier, optional")] string? tier = null)
|
|
||||||
{
|
|
||||||
var filtered = Customers.AsEnumerable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(state))
|
|
||||||
{
|
|
||||||
filtered = filtered.Where(c => c.State.Equals(state, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(tier))
|
|
||||||
{
|
|
||||||
filtered = filtered.Where(c => c.Tier.Equals(tier, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
var names = filtered.Select(c => c.Name).ToList();
|
|
||||||
return names.Any() ? string.Join(", ", names) : "No customers found";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.AI.Tools;
|
|
||||||
|
|
||||||
public class MathTool
|
|
||||||
{
|
|
||||||
[Description("Add two numbers together")]
|
|
||||||
public int Add(int a, int b) => a + b;
|
|
||||||
|
|
||||||
[Description("Multiply two numbers together")]
|
|
||||||
public int Multiply(int a, int b) => a * b;
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Svrnty.Sample.Data.Entities;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Database context for AI agent system with conversation history and business data
|
|
||||||
/// </summary>
|
|
||||||
public class AgentDbContext : DbContext
|
|
||||||
{
|
|
||||||
public AgentDbContext(DbContextOptions<AgentDbContext> options) : base(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DbSet<Conversation> Conversations => Set<Conversation>();
|
|
||||||
public DbSet<Revenue> Revenues => Set<Revenue>();
|
|
||||||
public DbSet<Customer> Customers => Set<Customer>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(modelBuilder);
|
|
||||||
|
|
||||||
// Configure Conversation entity
|
|
||||||
modelBuilder.Entity<Conversation>(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<Revenue>(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<Customer>(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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Design-time factory for creating AgentDbContext during migrations
|
|
||||||
/// </summary>
|
|
||||||
public class AgentDbContextFactory : IDesignTimeDbContextFactory<AgentDbContext>
|
|
||||||
{
|
|
||||||
public AgentDbContext CreateDbContext(string[] args)
|
|
||||||
{
|
|
||||||
var optionsBuilder = new DbContextOptionsBuilder<AgentDbContext>();
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents an AI agent conversation with message history
|
|
||||||
/// </summary>
|
|
||||||
[Table("conversations", Schema = "agent")]
|
|
||||||
public class Conversation
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
[Column("id")]
|
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// JSON array of messages in the conversation
|
|
||||||
/// </summary>
|
|
||||||
[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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convenience property to get/set messages as objects (not mapped to database)
|
|
||||||
/// </summary>
|
|
||||||
[NotMapped]
|
|
||||||
public List<ConversationMessage> Messages
|
|
||||||
{
|
|
||||||
get => string.IsNullOrEmpty(MessagesJson)
|
|
||||||
? new List<ConversationMessage>()
|
|
||||||
: JsonSerializer.Deserialize<List<ConversationMessage>>(MessagesJson) ?? new List<ConversationMessage>();
|
|
||||||
set => MessagesJson = JsonSerializer.Serialize(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Individual message in a conversation
|
|
||||||
/// </summary>
|
|
||||||
public class ConversationMessage
|
|
||||||
{
|
|
||||||
public string Role { get; set; } = string.Empty;
|
|
||||||
public string Content { get; set; } = string.Empty;
|
|
||||||
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents a customer in the system
|
|
||||||
/// </summary>
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Represents monthly revenue data
|
|
||||||
/// </summary>
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
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
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
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<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("MessagesJson")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasDefaultValue("[]")
|
|
||||||
.HasColumnName("messages");
|
|
||||||
|
|
||||||
b.Property<DateTime>("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<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)")
|
|
||||||
.HasColumnName("email");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)")
|
|
||||||
.HasColumnName("name");
|
|
||||||
|
|
||||||
b.Property<string>("State")
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)")
|
|
||||||
.HasColumnName("state");
|
|
||||||
|
|
||||||
b.Property<string>("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<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
|
||||||
.HasPrecision(18, 2)
|
|
||||||
.HasColumnType("decimal(18,2)")
|
|
||||||
.HasColumnName("amount");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("Month")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)")
|
|
||||||
.HasColumnName("month");
|
|
||||||
|
|
||||||
b.Property<int>("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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Svrnty.Sample.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class InitialCreate : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name: "agent");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "conversations",
|
|
||||||
schema: "agent",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
messages = table.Column<string>(type: "jsonb", nullable: false, defaultValue: "[]"),
|
|
||||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
updated_at = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
|
||||||
email = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
|
||||||
state = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
|
||||||
tier = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: true),
|
|
||||||
created_at = table.Column<DateTime>(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<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
|
||||||
month = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
|
||||||
amount = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
|
|
||||||
year = table.Column<int>(type: "integer", nullable: false),
|
|
||||||
created_at = table.Column<DateTime>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "conversations",
|
|
||||||
schema: "agent");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "customers",
|
|
||||||
schema: "agent");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "revenue",
|
|
||||||
schema: "agent");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
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<Guid>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("MessagesJson")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("jsonb")
|
|
||||||
.HasDefaultValue("[]")
|
|
||||||
.HasColumnName("messages");
|
|
||||||
|
|
||||||
b.Property<DateTime>("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<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)")
|
|
||||||
.HasColumnName("email");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(200)
|
|
||||||
.HasColumnType("character varying(200)")
|
|
||||||
.HasColumnName("name");
|
|
||||||
|
|
||||||
b.Property<string>("State")
|
|
||||||
.HasMaxLength(100)
|
|
||||||
.HasColumnType("character varying(100)")
|
|
||||||
.HasColumnName("state");
|
|
||||||
|
|
||||||
b.Property<string>("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<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<decimal>("Amount")
|
|
||||||
.HasPrecision(18, 2)
|
|
||||||
.HasColumnType("decimal(18,2)")
|
|
||||||
.HasColumnName("amount");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("Month")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(50)
|
|
||||||
.HasColumnType("character varying(50)")
|
|
||||||
.HasColumnName("month");
|
|
||||||
|
|
||||||
b.Property<int>("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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,143 +1,22 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
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;
|
||||||
using Svrnty.CQRS.FluentValidation;
|
using Svrnty.CQRS.FluentValidation;
|
||||||
// Temporarily disabled gRPC (ARM64 Mac build issues)
|
using Svrnty.CQRS.Grpc;
|
||||||
// using Svrnty.CQRS.Grpc;
|
|
||||||
using Svrnty.Sample;
|
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.MinimalApi;
|
||||||
using Svrnty.CQRS.DynamicQuery;
|
using Svrnty.CQRS.DynamicQuery;
|
||||||
using Svrnty.CQRS.Abstractions;
|
using Svrnty.CQRS.Abstractions;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Temporarily disabled gRPC configuration (ARM64 Mac build issues)
|
// Configure Kestrel to support both HTTP/1.1 (for REST APIs) and HTTP/2 (for gRPC)
|
||||||
// Using ASPNETCORE_URLS environment variable for endpoint configuration instead of Kestrel
|
|
||||||
// This avoids HTTPS certificate issues in Docker
|
|
||||||
/*
|
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
{
|
{
|
||||||
|
// Port 6000: HTTP/2 for gRPC
|
||||||
|
options.ListenLocalhost(6000, o => o.Protocols = HttpProtocols.Http2);
|
||||||
// Port 6001: HTTP/1.1 for HTTP API
|
// Port 6001: HTTP/1.1 for HTTP API
|
||||||
options.ListenLocalhost(6001, o => o.Protocols = HttpProtocols.Http1);
|
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<AgentDbContext>(options =>
|
|
||||||
options.UseNpgsql(connectionString));
|
|
||||||
|
|
||||||
// Configure Langfuse HTTP client for AI observability (required by ExecuteAgentCommandHandler)
|
|
||||||
var langfuseBaseUrl = builder.Configuration["Langfuse:BaseUrl"] ?? "http://localhost:3000";
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
builder.Services.AddScoped<LangfuseHttpClient>(sp =>
|
|
||||||
{
|
|
||||||
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
|
|
||||||
var httpClient = httpClientFactory.CreateClient();
|
|
||||||
httpClient.BaseAddress = new Uri(langfuseBaseUrl);
|
|
||||||
httpClient.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
|
|
||||||
var configuration = sp.GetRequiredService<IConfiguration>();
|
|
||||||
return new LangfuseHttpClient(httpClient, configuration);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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<string, object>
|
|
||||||
{
|
|
||||||
["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<HttpContext, string>(
|
|
||||||
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
|
// IMPORTANT: Register dynamic query dependencies FIRST
|
||||||
// (before AddSvrntyCqrs, so gRPC services can find the handlers)
|
// (before AddSvrntyCqrs, so gRPC services can find the handlers)
|
||||||
@ -145,36 +24,19 @@ builder.Services.AddTransient<PoweredSoft.Data.Core.IAsyncQueryableService, Simp
|
|||||||
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
builder.Services.AddTransient<PoweredSoft.DynamicQuery.Core.IQueryHandlerAsync, PoweredSoft.DynamicQuery.QueryHandlerAsync>();
|
||||||
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
builder.Services.AddDynamicQueryWithProvider<User, UserQueryableProvider>();
|
||||||
|
|
||||||
// Register AI Tools
|
|
||||||
builder.Services.AddSingleton<MathTool>();
|
|
||||||
builder.Services.AddScoped<DatabaseQueryTool>();
|
|
||||||
|
|
||||||
// Register Ollama AI client
|
|
||||||
var ollamaBaseUrl = builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434";
|
|
||||||
builder.Services.AddHttpClient<IChatClient, OllamaClient>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new Uri(ollamaBaseUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register commands and queries with validators
|
|
||||||
builder.Services.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
|
||||||
builder.Services.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
|
||||||
builder.Services.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
|
||||||
|
|
||||||
// Register AI agent command
|
|
||||||
builder.Services.AddCommand<ExecuteAgentCommand, AgentResponse, ExecuteAgentCommandHandler>();
|
|
||||||
|
|
||||||
// Configure CQRS with fluent API
|
// Configure CQRS with fluent API
|
||||||
builder.Services.AddSvrntyCqrs(cqrs =>
|
builder.Services.AddSvrntyCqrs(cqrs =>
|
||||||
{
|
{
|
||||||
// Temporarily disabled gRPC (ARM64 Mac build issues)
|
// Register commands and queries with validators
|
||||||
/*
|
cqrs.AddCommand<AddUserCommand, int, AddUserCommandHandler, AddUserCommandValidator>();
|
||||||
|
cqrs.AddCommand<RemoveUserCommand, RemoveUserCommandHandler>();
|
||||||
|
cqrs.AddQuery<FetchUserQuery, User, FetchUserQueryHandler>();
|
||||||
|
|
||||||
// Enable gRPC endpoints with reflection
|
// Enable gRPC endpoints with reflection
|
||||||
cqrs.AddGrpc(grpc =>
|
cqrs.AddGrpc(grpc =>
|
||||||
{
|
{
|
||||||
grpc.EnableReflection();
|
grpc.EnableReflection();
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
|
|
||||||
// Enable MinimalApi endpoints
|
// Enable MinimalApi endpoints
|
||||||
cqrs.AddMinimalApi(configure =>
|
cqrs.AddMinimalApi(configure =>
|
||||||
@ -185,56 +47,18 @@ builder.Services.AddSvrntyCqrs(cqrs =>
|
|||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
// Configure Health Checks
|
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddNpgSql(connectionString, name: "postgresql", tags: new[] { "ready", "db" });
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Run database migrations
|
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<AgentDbContext>();
|
|
||||||
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)
|
// Map all configured CQRS endpoints (gRPC, MinimalApi, and Dynamic Queries)
|
||||||
app.UseSvrntyCqrs();
|
app.UseSvrntyCqrs();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
|
|
||||||
// Prometheus metrics endpoint
|
|
||||||
app.MapPrometheusScrapingEndpoint();
|
|
||||||
|
|
||||||
// Health check endpoints
|
Console.WriteLine("Auto-Generated gRPC Server with Reflection, Validation, MinimalApi and Swagger");
|
||||||
app.MapHealthChecks("/health");
|
Console.WriteLine("gRPC (HTTP/2): http://localhost:6000");
|
||||||
app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
|
Console.WriteLine("HTTP API (HTTP/1.1): http://localhost:6001/api/command/* and http://localhost:6001/api/query/*");
|
||||||
{
|
|
||||||
Predicate = check => check.Tags.Contains("ready")
|
|
||||||
});
|
|
||||||
|
|
||||||
Console.WriteLine("Production-Ready AI Agent with Full Observability (HTTP-Only Mode)");
|
|
||||||
Console.WriteLine("═══════════════════════════════════════════════════════════");
|
|
||||||
Console.WriteLine("HTTP API: http://localhost:6001/api/command/* and /api/query/*");
|
|
||||||
Console.WriteLine("Swagger UI: http://localhost:6001/swagger");
|
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("Note: gRPC temporarily disabled (ARM64 Mac build issues)");
|
|
||||||
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();
|
app.Run();
|
||||||
|
|||||||
@ -8,18 +8,11 @@
|
|||||||
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
|
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- Temporarily disabled gRPC due to ARM64 Mac build issues with Grpc.Tools -->
|
|
||||||
<!-- Uncomment when gRPC support is needed -->
|
|
||||||
<!--
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
|
<Protobuf Include="Protos\*.proto" GrpcServices="Server" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
-->
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
|
|
||||||
<!-- Temporarily disabled gRPC packages (ARM64 Mac build issues) -->
|
|
||||||
<!--
|
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
<PackageReference Include="Grpc.AspNetCore" Version="2.71.0" />
|
||||||
<PackageReference Include="Grpc.AspNetCore.Server.Reflection" Version="2.71.0" />
|
<PackageReference Include="Grpc.AspNetCore.Server.Reflection" Version="2.71.0" />
|
||||||
<PackageReference Include="Grpc.Tools" Version="2.76.0">
|
<PackageReference Include="Grpc.Tools" Version="2.76.0">
|
||||||
@ -27,43 +20,22 @@
|
|||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Grpc.StatusProto" Version="2.71.0" />
|
<PackageReference Include="Grpc.StatusProto" Version="2.71.0" />
|
||||||
-->
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="9.0.0-preview.9.24556.5" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.AI.Ollama" Version="9.0.0-preview.9.24556.5" />
|
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.2" />
|
|
||||||
<PackageReference Include="OpenTelemetry" Version="1.10.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.10.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.10.0-beta.1" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.0" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.0.0-beta.13" />
|
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.10.0" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS\Svrnty.CQRS.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.Abstractions\Svrnty.CQRS.Abstractions.csproj" />
|
||||||
<!-- Temporarily disabled gRPC project references (ARM64 Mac build issues) -->
|
|
||||||
<!--
|
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Grpc\Svrnty.CQRS.Grpc.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.Grpc\Svrnty.CQRS.Grpc.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Generators\Svrnty.CQRS.Grpc.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Generators\Svrnty.CQRS.Grpc.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
-->
|
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.FluentValidation\Svrnty.CQRS.FluentValidation.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.MinimalApi\Svrnty.CQRS.MinimalApi.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.MinimalApi\Svrnty.CQRS.MinimalApi.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery\Svrnty.CQRS.DynamicQuery.csproj" />
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.DynamicQuery.MinimalApi\Svrnty.CQRS.DynamicQuery.MinimalApi.csproj" />
|
||||||
<!-- Keep abstractions for attributes like [GrpcIgnore] -->
|
|
||||||
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
|
<ProjectReference Include="..\Svrnty.CQRS.Grpc.Abstractions\Svrnty.CQRS.Grpc.Abstractions.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- Temporarily disabled gRPC proto generation targets (ARM64 Mac build issues) -->
|
<!-- Import the proto generation targets for testing (in production this would come from the NuGet package) -->
|
||||||
<!--
|
|
||||||
<Import Project="..\Svrnty.CQRS.Grpc.Generators\build\Svrnty.CQRS.Grpc.Generators.targets" />
|
<Import Project="..\Svrnty.CQRS.Grpc.Generators\build\Svrnty.CQRS.Grpc.Generators.targets" />
|
||||||
-->
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -3,26 +3,14 @@
|
|||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning",
|
"Microsoft.AspNetCore": "Warning",
|
||||||
"Microsoft.EntityFrameworkCore": "Warning"
|
"Microsoft.AspNetCore.Server.Kestrel": "Information"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Host=localhost;Database=svrnty;Username=postgres;Password=postgres;Include Error Detail=true"
|
|
||||||
},
|
|
||||||
"Ollama": {
|
|
||||||
"BaseUrl": "http://localhost:11434",
|
|
||||||
"Model": "qwen2.5-coder:7b"
|
|
||||||
},
|
|
||||||
"Langfuse": {
|
|
||||||
"BaseUrl": "http://localhost:3000",
|
|
||||||
"PublicKey": "pk-lf-4bf8a737-30d0-4c70-ae61-fbc6d3e5d028",
|
|
||||||
"SecretKey": "sk-lf-dbcb06e1-a172-40d9-9df2-f1e1ee1ced7a"
|
|
||||||
},
|
|
||||||
"Kestrel": {
|
"Kestrel": {
|
||||||
"Endpoints": {
|
"Endpoints": {
|
||||||
"Http": {
|
"Http": {
|
||||||
"Url": "http://localhost:6001",
|
"Url": "http://localhost:5000",
|
||||||
"Protocols": "Http1"
|
"Protocols": "Http2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,12 +9,16 @@
|
|||||||
"Kestrel": {
|
"Kestrel": {
|
||||||
"Endpoints": {
|
"Endpoints": {
|
||||||
"Http": {
|
"Http": {
|
||||||
"Url": "http://localhost:6001",
|
"Url": "http://localhost:5000",
|
||||||
"Protocols": "Http1"
|
"Protocols": "Http2"
|
||||||
|
},
|
||||||
|
"Https": {
|
||||||
|
"Url": "https://localhost:5001",
|
||||||
|
"Protocols": "Http2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"EndpointDefaults": {
|
"EndpointDefaults": {
|
||||||
"Protocols": "Http1"
|
"Protocols": "Http2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
#!/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 "═══════════════════════════════════════════════════════════"
|
|
||||||
389
TESTING_GUIDE.md
389
TESTING_GUIDE.md
@ -1,389 +0,0 @@
|
|||||||
# Production Stack Testing Guide
|
|
||||||
|
|
||||||
This guide provides instructions for testing your AI Agent production stack after resolving the Docker build issues.
|
|
||||||
|
|
||||||
## Current Status
|
|
||||||
|
|
||||||
**Build Status:** ❌ Failed at ~95%
|
|
||||||
**Issue:** gRPC source generator task (`WriteProtoFileTask`) not found in .NET 10 preview SDK
|
|
||||||
**Location:** `Svrnty.CQRS.Grpc.Generators`
|
|
||||||
|
|
||||||
## Build Issues to Resolve
|
|
||||||
|
|
||||||
### Issue 1: gRPC Generator Compatibility
|
|
||||||
```
|
|
||||||
error MSB4036: The "WriteProtoFileTask" task was not found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Possible Solutions:**
|
|
||||||
1. **Skip gRPC for Docker build:** Temporarily remove gRPC dependency from `Svrnty.Sample/Svrnty.Sample.csproj`
|
|
||||||
2. **Use different .NET SDK:** Try .NET 9 or stable .NET 8 instead of .NET 10 preview
|
|
||||||
3. **Fix the gRPC generator:** Update `Svrnty.CQRS.Grpc.Generators` to work with .NET 10 preview SDK
|
|
||||||
|
|
||||||
### Quick Fix: Disable gRPC for Testing
|
|
||||||
|
|
||||||
Edit `Svrnty.Sample/Svrnty.Sample.csproj` and comment out:
|
|
||||||
```xml
|
|
||||||
<!-- Temporarily disabled for Docker build -->
|
|
||||||
<!-- <ProjectReference Include="..\Svrnty.CQRS.Grpc\Svrnty.CQRS.Grpc.csproj" /> -->
|
|
||||||
```
|
|
||||||
|
|
||||||
Then rebuild:
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Once Build Succeeds
|
|
||||||
|
|
||||||
### Step 1: Start the Stack
|
|
||||||
```bash
|
|
||||||
# From project root
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
# Wait for services to start (2-3 minutes)
|
|
||||||
docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Verify Services
|
|
||||||
```bash
|
|
||||||
# Check all services are running
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
# Should show:
|
|
||||||
# api Up 0.0.0.0:6000-6001->6000-6001/tcp
|
|
||||||
# postgres Up 5432/tcp
|
|
||||||
# ollama Up 11434/tcp
|
|
||||||
# langfuse Up 3000/tcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Pull Ollama Model (One-time)
|
|
||||||
```bash
|
|
||||||
docker exec ollama ollama pull qwen2.5-coder:7b
|
|
||||||
# This downloads ~6.7GB, takes 5-10 minutes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Configure Langfuse (One-time)
|
|
||||||
1. Open http://localhost:3000
|
|
||||||
2. Create account (first-time setup)
|
|
||||||
3. Create a project (e.g., "AI Agent")
|
|
||||||
4. Go to Settings → API Keys
|
|
||||||
5. Copy the Public and Secret keys
|
|
||||||
6. Update `.env`:
|
|
||||||
```bash
|
|
||||||
LANGFUSE_PUBLIC_KEY=pk-lf-...
|
|
||||||
LANGFUSE_SECRET_KEY=sk-lf-...
|
|
||||||
```
|
|
||||||
7. Restart API to enable tracing:
|
|
||||||
```bash
|
|
||||||
docker compose restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Run Comprehensive Tests
|
|
||||||
```bash
|
|
||||||
# Execute the full test suite
|
|
||||||
./test-production-stack.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Suite Overview
|
|
||||||
|
|
||||||
The `test-production-stack.sh` script runs **7 comprehensive test phases**:
|
|
||||||
|
|
||||||
### Phase 1: Functional Testing (15 min)
|
|
||||||
- ✓ Health endpoint checks (API, Langfuse, Ollama, PostgreSQL)
|
|
||||||
- ✓ Agent math operations (simple and complex)
|
|
||||||
- ✓ Database queries (revenue, customers)
|
|
||||||
- ✓ Multi-turn conversations
|
|
||||||
|
|
||||||
**Tests:** 9 tests
|
|
||||||
**What it validates:** Core agent functionality and service connectivity
|
|
||||||
|
|
||||||
### Phase 2: Rate Limiting (5 min)
|
|
||||||
- ✓ Rate limit enforcement (100 req/min)
|
|
||||||
- ✓ HTTP 429 responses when exceeded
|
|
||||||
- ✓ Rate limit headers present
|
|
||||||
- ✓ Queue behavior (10 req queue depth)
|
|
||||||
|
|
||||||
**Tests:** 2 tests
|
|
||||||
**What it validates:** API protection and rate limiter configuration
|
|
||||||
|
|
||||||
### Phase 3: Observability (10 min)
|
|
||||||
- ✓ Langfuse trace generation
|
|
||||||
- ✓ Prometheus metrics collection
|
|
||||||
- ✓ HTTP request/response metrics
|
|
||||||
- ✓ Function call tracking
|
|
||||||
- ✓ Request counting accuracy
|
|
||||||
|
|
||||||
**Tests:** 4 tests
|
|
||||||
**What it validates:** Monitoring and debugging capabilities
|
|
||||||
|
|
||||||
### Phase 4: Load Testing (5 min)
|
|
||||||
- ✓ Concurrent request handling (20 parallel requests)
|
|
||||||
- ✓ Sustained load (30 seconds, 2 req/sec)
|
|
||||||
- ✓ Performance under stress
|
|
||||||
- ✓ Response time consistency
|
|
||||||
|
|
||||||
**Tests:** 2 tests
|
|
||||||
**What it validates:** Production-level performance and scalability
|
|
||||||
|
|
||||||
### Phase 5: Database Persistence (5 min)
|
|
||||||
- ✓ Conversation storage in PostgreSQL
|
|
||||||
- ✓ Conversation ID generation
|
|
||||||
- ✓ Seed data integrity (revenue, customers)
|
|
||||||
- ✓ Database query accuracy
|
|
||||||
|
|
||||||
**Tests:** 4 tests
|
|
||||||
**What it validates:** Data persistence and reliability
|
|
||||||
|
|
||||||
### Phase 6: Error Handling & Recovery (10 min)
|
|
||||||
- ✓ Invalid request handling (400/422 responses)
|
|
||||||
- ✓ Service restart recovery
|
|
||||||
- ✓ Graceful error messages
|
|
||||||
- ✓ Database connection resilience
|
|
||||||
|
|
||||||
**Tests:** 2 tests
|
|
||||||
**What it validates:** Production readiness and fault tolerance
|
|
||||||
|
|
||||||
### Total: ~50 minutes, 23+ tests
|
|
||||||
|
|
||||||
## Manual Testing Examples
|
|
||||||
|
|
||||||
### Test 1: Simple Math
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What is 5 + 3?"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"conversationId": "uuid-here",
|
|
||||||
"success": true,
|
|
||||||
"response": "The result of 5 + 3 is 8."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 2: Database Query
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What was our revenue in January 2025?"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Response:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"conversationId": "uuid-here",
|
|
||||||
"success": true,
|
|
||||||
"response": "The revenue for January 2025 was $245,000."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 3: Rate Limiting
|
|
||||||
```bash
|
|
||||||
# Send 110 requests quickly
|
|
||||||
for i in {1..110}; do
|
|
||||||
curl -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"test"}' &
|
|
||||||
done
|
|
||||||
wait
|
|
||||||
|
|
||||||
# First 100 succeed, next 10 queue, remaining get HTTP 429
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 4: Check Metrics
|
|
||||||
```bash
|
|
||||||
curl http://localhost:6001/metrics | grep http_server_request_duration
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Output:**
|
|
||||||
```
|
|
||||||
http_server_request_duration_seconds_count{...} 150
|
|
||||||
http_server_request_duration_seconds_sum{...} 45.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test 5: View Traces in Langfuse
|
|
||||||
1. Open http://localhost:3000/traces
|
|
||||||
2. Click on a trace to see:
|
|
||||||
- Agent execution span (root)
|
|
||||||
- Tool registration span
|
|
||||||
- LLM completion spans
|
|
||||||
- Function call spans (Add, DatabaseQuery, etc.)
|
|
||||||
- Timing breakdown
|
|
||||||
|
|
||||||
## Test Results Interpretation
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
- **>90% pass rate:** Production ready
|
|
||||||
- **80-90% pass rate:** Minor issues to address
|
|
||||||
- **<80% pass rate:** Significant issues, not production ready
|
|
||||||
|
|
||||||
### Common Test Failures
|
|
||||||
|
|
||||||
#### Failure: "Agent returned error or timeout"
|
|
||||||
**Cause:** Ollama model not pulled or API not responding
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
docker exec ollama ollama pull qwen2.5-coder:7b
|
|
||||||
docker compose restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Failure: "Service not running"
|
|
||||||
**Cause:** Docker container failed to start
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
docker compose logs [service-name]
|
|
||||||
docker compose up -d [service-name]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Failure: "No rate limit headers found"
|
|
||||||
**Cause:** Rate limiter not configured
|
|
||||||
**Fix:** Check `Program.cs:Svrnty.Sample/Program.cs:92-96` for rate limiter setup
|
|
||||||
|
|
||||||
#### Failure: "Traces not visible in Langfuse"
|
|
||||||
**Cause:** Langfuse keys not configured in `.env`
|
|
||||||
**Fix:** Follow Step 4 above to configure API keys
|
|
||||||
|
|
||||||
## Accessing Logs
|
|
||||||
|
|
||||||
### API Logs
|
|
||||||
```bash
|
|
||||||
docker compose logs -f api
|
|
||||||
```
|
|
||||||
|
|
||||||
### All Services
|
|
||||||
```bash
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
### Filter for Errors
|
|
||||||
```bash
|
|
||||||
docker compose logs | grep -i error
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stopping the Stack
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop all services
|
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Stop and remove volumes (clean slate)
|
|
||||||
docker compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Issue: Ollama Out of Memory
|
|
||||||
**Symptoms:** Agent responses timeout or return errors
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Increase Docker memory limit to 8GB+
|
|
||||||
# Docker Desktop → Settings → Resources → Memory
|
|
||||||
docker compose restart ollama
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: PostgreSQL Connection Failed
|
|
||||||
**Symptoms:** Database queries fail
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
docker compose logs postgres
|
|
||||||
# Check for port conflicts or permission issues
|
|
||||||
docker compose down -v
|
|
||||||
docker compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Langfuse Not Showing Traces
|
|
||||||
**Symptoms:** Metrics work but no traces in UI
|
|
||||||
**Solution:**
|
|
||||||
1. Verify keys in `.env` match Langfuse UI
|
|
||||||
2. Check API logs for OTLP export errors:
|
|
||||||
```bash
|
|
||||||
docker compose logs api | grep -i "otlp\|langfuse"
|
|
||||||
```
|
|
||||||
3. Restart API after updating keys:
|
|
||||||
```bash
|
|
||||||
docker compose restart api
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: Port Already in Use
|
|
||||||
**Symptoms:** `docker compose up` fails with "port already allocated"
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Find what's using the port
|
|
||||||
lsof -i :6001 # API HTTP
|
|
||||||
lsof -i :6000 # API gRPC
|
|
||||||
lsof -i :5432 # PostgreSQL
|
|
||||||
lsof -i :3000 # Langfuse
|
|
||||||
|
|
||||||
# Kill the process or change ports in docker-compose.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Expectations
|
|
||||||
|
|
||||||
### Response Times
|
|
||||||
- **Simple Math:** 1-2 seconds
|
|
||||||
- **Database Query:** 2-3 seconds
|
|
||||||
- **Complex Multi-step:** 3-5 seconds
|
|
||||||
|
|
||||||
### Throughput
|
|
||||||
- **Rate Limit:** 100 requests/minute
|
|
||||||
- **Queue Depth:** 10 requests
|
|
||||||
- **Concurrent Connections:** 20+ supported
|
|
||||||
|
|
||||||
### Resource Usage
|
|
||||||
- **Memory:** ~4GB total (Ollama ~3GB, others ~1GB)
|
|
||||||
- **CPU:** Variable based on query complexity
|
|
||||||
- **Disk:** ~10GB (Ollama model + Docker images)
|
|
||||||
|
|
||||||
## Production Deployment Checklist
|
|
||||||
|
|
||||||
Before deploying to production:
|
|
||||||
|
|
||||||
- [ ] All tests passing (>90% success rate)
|
|
||||||
- [ ] Langfuse API keys configured
|
|
||||||
- [ ] PostgreSQL credentials rotated
|
|
||||||
- [ ] Rate limits tuned for expected traffic
|
|
||||||
- [ ] Health checks validated
|
|
||||||
- [ ] Metrics dashboards created
|
|
||||||
- [ ] Alert rules configured
|
|
||||||
- [ ] Backup strategy implemented
|
|
||||||
- [ ] Secrets in environment variables (not code)
|
|
||||||
- [ ] Network policies configured
|
|
||||||
- [ ] TLS certificates installed (for HTTPS)
|
|
||||||
- [ ] Load balancer configured (if multi-instance)
|
|
||||||
|
|
||||||
## Next Steps After Testing
|
|
||||||
|
|
||||||
1. **Review test results:** Identify any failures and fix root causes
|
|
||||||
2. **Tune rate limits:** Adjust based on expected production traffic
|
|
||||||
3. **Create dashboards:** Build Grafana dashboards from Prometheus metrics
|
|
||||||
4. **Set up alerts:** Configure alerting for:
|
|
||||||
- API health check failures
|
|
||||||
- High error rates (>5%)
|
|
||||||
- High latency (P95 >5s)
|
|
||||||
- Database connection failures
|
|
||||||
5. **Optimize Ollama:** Fine-tune model parameters for your use case
|
|
||||||
6. **Scale testing:** Test with higher concurrency (50-100 parallel)
|
|
||||||
7. **Security audit:** Review authentication, authorization, input validation
|
|
||||||
|
|
||||||
## Support Resources
|
|
||||||
|
|
||||||
- **Project README:** [README.md](./README.md)
|
|
||||||
- **Deployment Guide:** [DEPLOYMENT_README.md](./DEPLOYMENT_README.md)
|
|
||||||
- **Docker Compose:** [docker-compose.yml](./docker-compose.yml)
|
|
||||||
- **Test Script:** [test-production-stack.sh](./test-production-stack.sh)
|
|
||||||
|
|
||||||
## Getting Help
|
|
||||||
|
|
||||||
If tests fail or you encounter issues:
|
|
||||||
1. Check logs: `docker compose logs -f`
|
|
||||||
2. Review this guide's troubleshooting section
|
|
||||||
3. Verify all prerequisites are met
|
|
||||||
4. Check for port conflicts or resource constraints
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Test Script Version:** 1.0
|
|
||||||
**Last Updated:** 2025-11-08
|
|
||||||
**Estimated Total Test Time:** ~50 minutes
|
|
||||||
36
TestClient.csx
Normal file
36
TestClient.csx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env dotnet-script
|
||||||
|
#r "nuget: Grpc.Net.Client, 2.70.0"
|
||||||
|
#r "nuget: Google.Protobuf, 3.28.3"
|
||||||
|
#r "nuget: Grpc.Tools, 2.70.0"
|
||||||
|
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Grpc.Core;
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
// We'll use reflection/dynamic to call the gRPC service
|
||||||
|
// This is a simple HTTP/2 test
|
||||||
|
|
||||||
|
var handler = new HttpClientHandler
|
||||||
|
{
|
||||||
|
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
|
||||||
|
};
|
||||||
|
|
||||||
|
using var channel = GrpcChannel.ForAddress("http://localhost:5000", new GrpcChannelOptions
|
||||||
|
{
|
||||||
|
HttpHandler = handler
|
||||||
|
});
|
||||||
|
|
||||||
|
Console.WriteLine("Connected to gRPC server at http://localhost:5000");
|
||||||
|
Console.WriteLine("Channel state: " + channel.State);
|
||||||
|
|
||||||
|
// Test basic connectivity
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await channel.ConnectAsync();
|
||||||
|
Console.WriteLine("Successfully connected!");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Connection failed: {ex.Message}");
|
||||||
|
}
|
||||||
100
TestGrpcClient/Program.cs
Normal file
100
TestGrpcClient/Program.cs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
using Grpc.Core;
|
||||||
|
using Grpc.Net.Client;
|
||||||
|
using Svrnty.CQRS.Grpc.Sample.Grpc;
|
||||||
|
|
||||||
|
Console.WriteLine("=== gRPC Client Validation Test ===");
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Create a gRPC channel
|
||||||
|
using var channel = GrpcChannel.ForAddress("http://localhost:5000");
|
||||||
|
|
||||||
|
// Create the gRPC client
|
||||||
|
var client = new CommandService.CommandServiceClient(channel);
|
||||||
|
|
||||||
|
// Test 1: Valid request
|
||||||
|
Console.WriteLine("Test 1: Valid AddUser request...");
|
||||||
|
var validRequest = new AddUserCommandRequest
|
||||||
|
{
|
||||||
|
Name = "John Doe",
|
||||||
|
Email = "john.doe@example.com",
|
||||||
|
Age = 30
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.AddUserAsync(validRequest);
|
||||||
|
Console.WriteLine($"✓ Success! User added with ID: {response.Result}");
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✗ Unexpected error: {ex.Status.Detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Test 2: Invalid email (empty)
|
||||||
|
Console.WriteLine("Test 2: Invalid email (empty)...");
|
||||||
|
var invalidEmailRequest = new AddUserCommandRequest
|
||||||
|
{
|
||||||
|
Name = "Jane Doe",
|
||||||
|
Email = "",
|
||||||
|
Age = 25
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.AddUserAsync(invalidEmailRequest);
|
||||||
|
Console.WriteLine($"✗ Unexpected success! Validation should have failed.");
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Validation caught! Status: {ex.StatusCode}");
|
||||||
|
Console.WriteLine($" Message: {ex.Status.Detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Test 3: Invalid email format
|
||||||
|
Console.WriteLine("Test 3: Invalid email format...");
|
||||||
|
var badEmailRequest = new AddUserCommandRequest
|
||||||
|
{
|
||||||
|
Name = "Bob Smith",
|
||||||
|
Email = "not-an-email",
|
||||||
|
Age = 40
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.AddUserAsync(badEmailRequest);
|
||||||
|
Console.WriteLine($"✗ Unexpected success! Validation should have failed.");
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Validation caught! Status: {ex.StatusCode}");
|
||||||
|
Console.WriteLine($" Message: {ex.Status.Detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
|
||||||
|
// Test 4: Invalid age (0)
|
||||||
|
Console.WriteLine("Test 4: Invalid age (0)...");
|
||||||
|
var invalidAgeRequest = new AddUserCommandRequest
|
||||||
|
{
|
||||||
|
Name = "Alice Brown",
|
||||||
|
Email = "alice@example.com",
|
||||||
|
Age = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.AddUserAsync(invalidAgeRequest);
|
||||||
|
Console.WriteLine($"✗ Unexpected success! Validation should have failed.");
|
||||||
|
}
|
||||||
|
catch (RpcException ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"✓ Validation caught! Status: {ex.StatusCode}");
|
||||||
|
Console.WriteLine($" Message: {ex.Status.Detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("All tests completed!");
|
||||||
58
TestGrpcClient/Protos/cqrs_services.proto
Normal file
58
TestGrpcClient/Protos/cqrs_services.proto
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
option csharp_namespace = "Svrnty.CQRS.Grpc.Sample.Grpc";
|
||||||
|
|
||||||
|
package cqrs;
|
||||||
|
|
||||||
|
// Command service for CQRS operations
|
||||||
|
service CommandService {
|
||||||
|
// Adds a new user and returns the user ID
|
||||||
|
rpc AddUser (AddUserCommandRequest) returns (AddUserCommandResponse);
|
||||||
|
|
||||||
|
// Removes a user
|
||||||
|
rpc RemoveUser (RemoveUserCommandRequest) returns (RemoveUserCommandResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query service for CQRS operations
|
||||||
|
service QueryService {
|
||||||
|
// Fetches a user by ID
|
||||||
|
rpc FetchUser (FetchUserQueryRequest) returns (FetchUserQueryResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request message for adding a user
|
||||||
|
message AddUserCommandRequest {
|
||||||
|
string name = 1;
|
||||||
|
string email = 2;
|
||||||
|
int32 age = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response message containing the added user ID
|
||||||
|
message AddUserCommandResponse {
|
||||||
|
int32 result = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request message for removing a user
|
||||||
|
message RemoveUserCommandRequest {
|
||||||
|
int32 user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response message for remove user (empty)
|
||||||
|
message RemoveUserCommandResponse {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request message for fetching a user
|
||||||
|
message FetchUserQueryRequest {
|
||||||
|
int32 user_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response message containing the user
|
||||||
|
message FetchUserQueryResponse {
|
||||||
|
User result = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User entity
|
||||||
|
message User {
|
||||||
|
int32 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string email = 3;
|
||||||
|
}
|
||||||
23
TestGrpcClient/TestGrpcClient.csproj
Normal file
23
TestGrpcClient/TestGrpcClient.csproj
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Protobuf Include="Protos\*.proto" GrpcServices="Client" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Google.Protobuf" Version="3.33.0" />
|
||||||
|
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
|
||||||
|
<PackageReference Include="Grpc.Tools" Version="2.76.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@ -1,122 +0,0 @@
|
|||||||
services:
|
|
||||||
# === .NET AI AGENT API ===
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: svrnty-api
|
|
||||||
ports:
|
|
||||||
# Temporarily disabled gRPC (ARM64 Mac build issues)
|
|
||||||
# - "6000:6000" # gRPC
|
|
||||||
- "6001:6001" # HTTP
|
|
||||||
environment:
|
|
||||||
- ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production}
|
|
||||||
# HTTP-only mode (gRPC temporarily disabled)
|
|
||||||
- ASPNETCORE_URLS=http://+:6001
|
|
||||||
- ASPNETCORE_HTTPS_PORTS=
|
|
||||||
- ASPNETCORE_HTTP_PORTS=6001
|
|
||||||
- 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:
|
|
||||||
# Using v2 - v3 requires ClickHouse which adds complexity
|
|
||||||
image: langfuse/langfuse:2
|
|
||||||
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
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
-- 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 $$;
|
|
||||||
@ -1,510 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# AI Agent Production Stack - Comprehensive Test Suite
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
set -e # Exit on error
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Counters
|
|
||||||
TOTAL_TESTS=0
|
|
||||||
PASSED_TESTS=0
|
|
||||||
FAILED_TESTS=0
|
|
||||||
|
|
||||||
# Test results array
|
|
||||||
declare -a TEST_RESULTS
|
|
||||||
|
|
||||||
# Function to print section header
|
|
||||||
print_header() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo -e "${BLUE} $1${NC}"
|
|
||||||
echo -e "${BLUE}═══════════════════════════════════════════════════════════${NC}"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to print test result
|
|
||||||
print_test() {
|
|
||||||
local name="$1"
|
|
||||||
local status="$2"
|
|
||||||
local message="$3"
|
|
||||||
|
|
||||||
TOTAL_TESTS=$((TOTAL_TESTS + 1))
|
|
||||||
|
|
||||||
if [ "$status" = "PASS" ]; then
|
|
||||||
echo -e "${GREEN}✓${NC} $name"
|
|
||||||
PASSED_TESTS=$((PASSED_TESTS + 1))
|
|
||||||
TEST_RESULTS+=("PASS: $name")
|
|
||||||
else
|
|
||||||
echo -e "${RED}✗${NC} $name - $message"
|
|
||||||
FAILED_TESTS=$((FAILED_TESTS + 1))
|
|
||||||
TEST_RESULTS+=("FAIL: $name - $message")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to check HTTP endpoint
|
|
||||||
check_http() {
|
|
||||||
local url="$1"
|
|
||||||
local expected_code="${2:-200}"
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "$expected_code" ]; then
|
|
||||||
return 0
|
|
||||||
else
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PRE-FLIGHT CHECKS
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PRE-FLIGHT CHECKS"
|
|
||||||
|
|
||||||
# Check Docker services
|
|
||||||
echo "Checking Docker services..."
|
|
||||||
SERVICES=("api" "postgres" "ollama" "langfuse")
|
|
||||||
|
|
||||||
for service in "${SERVICES[@]}"; do
|
|
||||||
if docker compose ps "$service" 2>/dev/null | grep -q "Up"; then
|
|
||||||
print_test "Docker service: $service" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Docker service: $service" "FAIL" "Service not running"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait for services to be ready
|
|
||||||
echo ""
|
|
||||||
echo "Waiting for services to be ready..."
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 1: FUNCTIONAL TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 1: FUNCTIONAL TESTING (Health Checks & Agent Queries)"
|
|
||||||
|
|
||||||
# Test 1.1: API Health Check
|
|
||||||
if check_http "http://localhost:6001/health" 200; then
|
|
||||||
print_test "API Health Endpoint" "PASS"
|
|
||||||
else
|
|
||||||
print_test "API Health Endpoint" "FAIL" "HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.2: API Readiness Check
|
|
||||||
if check_http "http://localhost:6001/health/ready" 200; then
|
|
||||||
print_test "API Readiness Endpoint" "PASS"
|
|
||||||
else
|
|
||||||
print_test "API Readiness Endpoint" "FAIL" "HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.3: Prometheus Metrics Endpoint
|
|
||||||
if check_http "http://localhost:6001/metrics" 200; then
|
|
||||||
print_test "Prometheus Metrics Endpoint" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Prometheus Metrics Endpoint" "FAIL" "HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.4: Langfuse Health
|
|
||||||
if check_http "http://localhost:3000/api/public/health" 200; then
|
|
||||||
print_test "Langfuse Health Endpoint" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Langfuse Health Endpoint" "FAIL" "HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.5: Ollama API
|
|
||||||
if check_http "http://localhost:11434/api/tags" 200; then
|
|
||||||
print_test "Ollama API Endpoint" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Ollama API Endpoint" "FAIL" "HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.6: Math Operation (Simple)
|
|
||||||
echo ""
|
|
||||||
echo "Testing agent with math operation..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What is 5 + 3?"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
print_test "Agent Math Query (5 + 3)" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Agent Math Query (5 + 3)" "FAIL" "Agent returned error or timeout"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.7: Math Operation (Complex)
|
|
||||||
echo "Testing agent with complex math..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"Calculate (5 + 3) multiplied by 2"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
print_test "Agent Complex Math Query" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Agent Complex Math Query" "FAIL" "Agent returned error or timeout"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.8: Database Query
|
|
||||||
echo "Testing agent with database query..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What was our revenue in January 2025?"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
print_test "Agent Database Query (Revenue)" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Agent Database Query (Revenue)" "FAIL" "Agent returned error or timeout"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test 1.9: Customer Query
|
|
||||||
echo "Testing agent with customer query..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"How many Enterprise customers do we have?"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
print_test "Agent Customer Query" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Agent Customer Query" "FAIL" "Agent returned error or timeout"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 2: RATE LIMITING TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 2: RATE LIMITING TESTING"
|
|
||||||
|
|
||||||
echo "Testing rate limit (100 req/min)..."
|
|
||||||
echo "Sending 110 requests in parallel..."
|
|
||||||
|
|
||||||
SUCCESS=0
|
|
||||||
RATE_LIMITED=0
|
|
||||||
|
|
||||||
for i in {1..110}; do
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"prompt\":\"test $i\"}" 2>/dev/null) &
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "200" ]; then
|
|
||||||
SUCCESS=$((SUCCESS + 1))
|
|
||||||
elif [ "$HTTP_CODE" = "429" ]; then
|
|
||||||
RATE_LIMITED=$((RATE_LIMITED + 1))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
wait
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Results: $SUCCESS successful, $RATE_LIMITED rate-limited"
|
|
||||||
|
|
||||||
if [ "$RATE_LIMITED" -gt 0 ]; then
|
|
||||||
print_test "Rate Limiting Enforcement" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Rate Limiting Enforcement" "FAIL" "No requests were rate-limited (expected some 429s)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test rate limit headers
|
|
||||||
RESPONSE_HEADERS=$(curl -sI -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"test"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE_HEADERS" | grep -qi "RateLimit"; then
|
|
||||||
print_test "Rate Limit Headers Present" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Rate Limit Headers Present" "FAIL" "No rate limit headers found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 3: OBSERVABILITY TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 3: OBSERVABILITY TESTING"
|
|
||||||
|
|
||||||
# Generate test traces
|
|
||||||
echo "Generating diverse traces for Langfuse..."
|
|
||||||
|
|
||||||
# Simple query
|
|
||||||
curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"Hello"}' > /dev/null 2>&1
|
|
||||||
|
|
||||||
# Function call
|
|
||||||
curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What is 42 * 17?"}' > /dev/null 2>&1
|
|
||||||
|
|
||||||
# Database query
|
|
||||||
curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"Show revenue for March 2025"}' > /dev/null 2>&1
|
|
||||||
|
|
||||||
sleep 2 # Allow traces to be exported
|
|
||||||
|
|
||||||
print_test "Trace Generation" "PASS"
|
|
||||||
echo " ${YELLOW}→${NC} Check traces at: http://localhost:3000/traces"
|
|
||||||
|
|
||||||
# Test Prometheus metrics
|
|
||||||
METRICS=$(curl -s http://localhost:6001/metrics 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$METRICS" | grep -q "http_server_request_duration_seconds"; then
|
|
||||||
print_test "Prometheus HTTP Metrics" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Prometheus HTTP Metrics" "FAIL" "Metrics not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if echo "$METRICS" | grep -q "http_client_request_duration_seconds"; then
|
|
||||||
print_test "Prometheus HTTP Client Metrics" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Prometheus HTTP Client Metrics" "FAIL" "Metrics not found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if metrics show actual requests
|
|
||||||
REQUEST_COUNT=$(echo "$METRICS" | grep "http_server_request_duration_seconds_count" | head -1 | awk '{print $NF}')
|
|
||||||
if [ -n "$REQUEST_COUNT" ] && [ "$REQUEST_COUNT" -gt 0 ]; then
|
|
||||||
print_test "Metrics Recording Requests" "PASS"
|
|
||||||
echo " ${YELLOW}→${NC} Total requests recorded: $REQUEST_COUNT"
|
|
||||||
else
|
|
||||||
print_test "Metrics Recording Requests" "FAIL" "No requests recorded in metrics"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 4: LOAD TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 4: LOAD TESTING"
|
|
||||||
|
|
||||||
echo "Running concurrent request test (20 requests)..."
|
|
||||||
|
|
||||||
START_TIME=$(date +%s)
|
|
||||||
CONCURRENT_SUCCESS=0
|
|
||||||
CONCURRENT_FAIL=0
|
|
||||||
|
|
||||||
for i in {1..20}; do
|
|
||||||
(
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"prompt\":\"Calculate $i + $i\"}" 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
echo "success" >> /tmp/load_test_results.txt
|
|
||||||
else
|
|
||||||
echo "fail" >> /tmp/load_test_results.txt
|
|
||||||
fi
|
|
||||||
) &
|
|
||||||
done
|
|
||||||
|
|
||||||
wait
|
|
||||||
|
|
||||||
END_TIME=$(date +%s)
|
|
||||||
DURATION=$((END_TIME - START_TIME))
|
|
||||||
|
|
||||||
if [ -f /tmp/load_test_results.txt ]; then
|
|
||||||
CONCURRENT_SUCCESS=$(grep -c "success" /tmp/load_test_results.txt 2>/dev/null || echo "0")
|
|
||||||
CONCURRENT_FAIL=$(grep -c "fail" /tmp/load_test_results.txt 2>/dev/null || echo "0")
|
|
||||||
rm /tmp/load_test_results.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Results: $CONCURRENT_SUCCESS successful, $CONCURRENT_FAIL failed (${DURATION}s)"
|
|
||||||
|
|
||||||
if [ "$CONCURRENT_SUCCESS" -ge 15 ]; then
|
|
||||||
print_test "Concurrent Load Handling (20 requests)" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Concurrent Load Handling (20 requests)" "FAIL" "Only $CONCURRENT_SUCCESS succeeded"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Sustained load test (30 seconds)
|
|
||||||
echo ""
|
|
||||||
echo "Running sustained load test (30 seconds, 2 req/sec)..."
|
|
||||||
|
|
||||||
START_TIME=$(date +%s)
|
|
||||||
END_TIME=$((START_TIME + 30))
|
|
||||||
SUSTAINED_SUCCESS=0
|
|
||||||
SUSTAINED_FAIL=0
|
|
||||||
|
|
||||||
while [ $(date +%s) -lt $END_TIME ]; do
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"What is 2 + 2?"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"success":true'; then
|
|
||||||
SUSTAINED_SUCCESS=$((SUSTAINED_SUCCESS + 1))
|
|
||||||
else
|
|
||||||
SUSTAINED_FAIL=$((SUSTAINED_FAIL + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
sleep 0.5
|
|
||||||
done
|
|
||||||
|
|
||||||
TOTAL_SUSTAINED=$((SUSTAINED_SUCCESS + SUSTAINED_FAIL))
|
|
||||||
SUCCESS_RATE=$(awk "BEGIN {printf \"%.1f\", ($SUSTAINED_SUCCESS / $TOTAL_SUSTAINED) * 100}")
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Results: $SUSTAINED_SUCCESS/$TOTAL_SUSTAINED successful (${SUCCESS_RATE}%)"
|
|
||||||
|
|
||||||
if [ "$SUCCESS_RATE" > "90" ]; then
|
|
||||||
print_test "Sustained Load Handling (30s)" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Sustained Load Handling (30s)" "FAIL" "Success rate: ${SUCCESS_RATE}%"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 5: DATABASE PERSISTENCE TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 5: DATABASE PERSISTENCE TESTING"
|
|
||||||
|
|
||||||
# Test conversation persistence
|
|
||||||
echo "Testing conversation persistence..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"prompt":"Remember that my favorite number is 42"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if echo "$RESPONSE" | grep -q '"conversationId"'; then
|
|
||||||
CONV_ID=$(echo "$RESPONSE" | grep -o '"conversationId":"[^"]*"' | cut -d'"' -f4)
|
|
||||||
print_test "Conversation Creation" "PASS"
|
|
||||||
echo " ${YELLOW}→${NC} Conversation ID: $CONV_ID"
|
|
||||||
|
|
||||||
# Verify in database
|
|
||||||
DB_CHECK=$(docker exec postgres psql -U postgres -d svrnty -t -c \
|
|
||||||
"SELECT COUNT(*) FROM agent.conversations WHERE id='$CONV_ID';" 2>/dev/null | tr -d ' ')
|
|
||||||
|
|
||||||
if [ "$DB_CHECK" = "1" ]; then
|
|
||||||
print_test "Conversation DB Persistence" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Conversation DB Persistence" "FAIL" "Not found in database"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
print_test "Conversation Creation" "FAIL" "No conversation ID returned"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify seed data
|
|
||||||
echo ""
|
|
||||||
echo "Verifying seed data..."
|
|
||||||
|
|
||||||
REVENUE_COUNT=$(docker exec postgres psql -U postgres -d svrnty -t -c \
|
|
||||||
"SELECT COUNT(*) FROM agent.revenues;" 2>/dev/null | tr -d ' ')
|
|
||||||
|
|
||||||
if [ "$REVENUE_COUNT" -gt 0 ]; then
|
|
||||||
print_test "Revenue Seed Data" "PASS"
|
|
||||||
echo " ${YELLOW}→${NC} Revenue records: $REVENUE_COUNT"
|
|
||||||
else
|
|
||||||
print_test "Revenue Seed Data" "FAIL" "No revenue data found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
CUSTOMER_COUNT=$(docker exec postgres psql -U postgres -d svrnty -t -c \
|
|
||||||
"SELECT COUNT(*) FROM agent.customers;" 2>/dev/null | tr -d ' ')
|
|
||||||
|
|
||||||
if [ "$CUSTOMER_COUNT" -gt 0 ]; then
|
|
||||||
print_test "Customer Seed Data" "PASS"
|
|
||||||
echo " ${YELLOW}→${NC} Customer records: $CUSTOMER_COUNT"
|
|
||||||
else
|
|
||||||
print_test "Customer Seed Data" "FAIL" "No customer data found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# PHASE 6: ERROR HANDLING & RECOVERY TESTING
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "PHASE 6: ERROR HANDLING & RECOVERY TESTING"
|
|
||||||
|
|
||||||
# Test graceful error handling
|
|
||||||
echo "Testing invalid request handling..."
|
|
||||||
RESPONSE=$(curl -s -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"invalid":"json structure"}' 2>/dev/null)
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:6001/api/command/executeAgent \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"invalid":"json structure"}' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "422" ]; then
|
|
||||||
print_test "Invalid Request Handling" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Invalid Request Handling" "FAIL" "Expected 400/422, got $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test service restart capability
|
|
||||||
echo ""
|
|
||||||
echo "Testing service restart (API)..."
|
|
||||||
docker compose restart api > /dev/null 2>&1
|
|
||||||
sleep 10 # Wait for restart
|
|
||||||
|
|
||||||
if check_http "http://localhost:6001/health" 200; then
|
|
||||||
print_test "Service Restart Recovery" "PASS"
|
|
||||||
else
|
|
||||||
print_test "Service Restart Recovery" "FAIL" "Service did not recover"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
# FINAL REPORT
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
print_header "TEST SUMMARY"
|
|
||||||
|
|
||||||
echo "Total Tests: $TOTAL_TESTS"
|
|
||||||
echo -e "${GREEN}Passed: $PASSED_TESTS${NC}"
|
|
||||||
echo -e "${RED}Failed: $FAILED_TESTS${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
SUCCESS_PERCENTAGE=$(awk "BEGIN {printf \"%.1f\", ($PASSED_TESTS / $TOTAL_TESTS) * 100}")
|
|
||||||
echo "Success Rate: ${SUCCESS_PERCENTAGE}%"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
print_header "ACCESS POINTS"
|
|
||||||
|
|
||||||
echo "API Endpoints:"
|
|
||||||
echo " • HTTP API: http://localhost:6001/api/command/executeAgent"
|
|
||||||
echo " • gRPC API: http://localhost:6000"
|
|
||||||
echo " • Swagger UI: http://localhost:6001/swagger"
|
|
||||||
echo " • Health: http://localhost:6001/health"
|
|
||||||
echo " • Metrics: http://localhost:6001/metrics"
|
|
||||||
echo ""
|
|
||||||
echo "Monitoring:"
|
|
||||||
echo " • Langfuse UI: http://localhost:3000"
|
|
||||||
echo " • Ollama API: http://localhost:11434"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
print_header "PRODUCTION READINESS CHECKLIST"
|
|
||||||
|
|
||||||
echo "Infrastructure:"
|
|
||||||
if [ "$PASSED_TESTS" -ge $((TOTAL_TESTS * 70 / 100)) ]; then
|
|
||||||
echo -e " ${GREEN}✓${NC} Docker containerization"
|
|
||||||
echo -e " ${GREEN}✓${NC} Multi-service orchestration"
|
|
||||||
echo -e " ${GREEN}✓${NC} Health checks configured"
|
|
||||||
else
|
|
||||||
echo -e " ${YELLOW}⚠${NC} Some infrastructure tests failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Observability:"
|
|
||||||
echo -e " ${GREEN}✓${NC} Prometheus metrics enabled"
|
|
||||||
echo -e " ${GREEN}✓${NC} Langfuse tracing configured"
|
|
||||||
echo -e " ${GREEN}✓${NC} Health endpoints active"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Reliability:"
|
|
||||||
echo -e " ${GREEN}✓${NC} Database persistence"
|
|
||||||
echo -e " ${GREEN}✓${NC} Rate limiting active"
|
|
||||||
echo -e " ${GREEN}✓${NC} Error handling tested"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══════════════════════════════════════════════════════════"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Exit with appropriate code
|
|
||||||
if [ "$FAILED_TESTS" -eq 0 ]; then
|
|
||||||
echo -e "${GREEN}All tests passed! Stack is production-ready.${NC}"
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Some tests failed. Review the report above.${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
Loading…
Reference in New Issue
Block a user