Skip to content
This repository has been archived by the owner on Jul 12, 2024. It is now read-only.

Commit

Permalink
Merge pull request #31 from foxminchan/feat/semantic_search
Browse files Browse the repository at this point in the history
feat: init ai service
  • Loading branch information
foxminchan committed Jun 8, 2024
2 parents 7525183 + 3598ea2 commit a8b08c8
Show file tree
Hide file tree
Showing 34 changed files with 422 additions and 162 deletions.
6 changes: 6 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PollyVersion>8.4.0</PollyVersion>
<AspireVersion>8.0.1</AspireVersion>
<AspirantVersion>0.0.3</AspirantVersion>
<AspireUnstablePackagesVersion>8.0.1-preview.8.24267.1</AspireUnstablePackagesVersion>
</PropertyGroup>
<ItemGroup>
<!-- Aspire -->
Expand All @@ -22,8 +23,11 @@
<PackageVersion Include="Aspire.StackExchange.Redis" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Npgsql" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="$(AspireVersion)" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="$(AspireUnstablePackagesVersion)" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="8.5.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="8.0.1" />
<!-- AI -->
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.14.1" />
<!-- Ardalis -->
<PackageVersion Include="Ardalis.GuardClauses" Version="4.5.0" />
<PackageVersion Include="Ardalis.Result.AspNetCore" Version="9.1.0" />
Expand All @@ -45,6 +49,8 @@
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(AspnetVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="$(AspnetVersion)" />
<!-- Entity Framework Core -->
<PackageVersion Include="Pgvector" Version="0.2.0" />
<PackageVersion Include="Pgvector.EntityFrameworkCore" Version="0.2.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="$(EfVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="$(EfVersion)" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(EfVersion)" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace RookieShop.ApiService.Endpoints.Products;

public sealed record SearchProductRequest(
string Context,
int PageIndex,
int PageSize);
10 changes: 10 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Products/Search.Response.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Ardalis.Result;
using RookieShop.ApiService.ViewModels.Products;

namespace RookieShop.ApiService.Endpoints.Products;

public sealed class SearchProductResponse
{
public PagedInfo? PagedInfo { get; set; }
public List<ProductVm>? Products { get; set; } = [];
}
43 changes: 43 additions & 0 deletions src/RookieShop.ApiService/Endpoints/Products/Search.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using MediatR;
using Microsoft.AspNetCore.Http.HttpResults;
using RookieShop.ApiService.ViewModels.Products;
using RookieShop.Application.Products.Queries.Search;
using RookieShop.Infrastructure.Endpoints.Abstractions;
using RookieShop.Infrastructure.RateLimiter;

namespace RookieShop.ApiService.Endpoints.Products;

public sealed class Search(ISender sender) : IEndpoint<Ok<SearchProductResponse>, SearchProductRequest>
{
public void MapEndpoint(IEndpointRouteBuilder app) =>
app.MapGet("/products/search",
async (
string context,
int pageNumber = 1,
int pageSize = 0) =>
await HandleAsync(new(context, pageNumber, pageSize)))
.Produces<Ok<SearchProductResponse>>()
.WithTags(nameof(Products))
.WithName("Search Products")
.MapToApiVersion(new(1, 0))
.RequirePerIpRateLimit();

public async Task<Ok<SearchProductResponse>> HandleAsync(SearchProductRequest request,
CancellationToken cancellationToken = default)
{
SearchProductQuery query = new(
request.Context,
request.PageIndex,
request.PageSize);

var result = await sender.Send(query, cancellationToken);

SearchProductResponse response = new()
{
PagedInfo = result.PagedInfo,
Products = result.Value.ToProductVm()
};

return TypedResults.Ok(response);
}
}
3 changes: 2 additions & 1 deletion src/RookieShop.ApiService/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,14 @@
}
else
{
app.UseHttpsRedirection();
app.UseExceptionHandler("/error");
app.UseHsts();
}

app.UseAntiforgery();

app.UseHttpsRedirection();

app.UseResponseCompression();

app.MapInfrastructure();
Expand Down
2 changes: 1 addition & 1 deletion src/RookieShop.ApiService/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@
"Port": 587,
"Email": "nguyenxuannhan.dev@gmail.com"
}
}
}
9 changes: 9 additions & 0 deletions src/RookieShop.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
var db = builder
.AddPostgres("db", postgresUser, postgresPassword, 5432)
.WithDataBindMount("../../mnt/postgres")
.WithImage("ankane/pgvector")
.WithImageTag("latest")
.WithPgAdmin();
var shopDb = db.AddDatabase("shopdb");
var userDb = db.AddDatabase("userdb");
Expand All @@ -40,6 +42,11 @@

var blobs = storage.AddBlobs("blobs");

// OpenAI
const string openAiName = "openai";
const string textEmbeddingName = "text-embedding-3-small";
var openAi = builder.AddConnectionString(openAiName);

// Services and applications
var identityService = builder
.AddProject<RookieShop_IdentityService>("identity-service")
Expand All @@ -52,7 +59,9 @@
.AddProject<RookieShop_ApiService>("api-service")
.WithReference(redis)
.WithReference(shopDb)
.WithReference(openAi)
.WithEnvironment("SmtpSettings__Secret", emailSecret)
.WithEnvironment("AIOptions__OpenAI__EmbeddingName", textEmbeddingName)
.WithEnvironment("AzuriteSettings__ConnectionString", blobs.WithEndpoint())
.WithEnvironment("OpenIdSettings__Authority", identityService.GetEndpoint(protocol));

Expand Down
6 changes: 4 additions & 2 deletions src/RookieShop.AppHost/RookieShop.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@
<ItemGroup>
<PackageJsons Include="..\..\ui\*\package.json" />
</ItemGroup>
<Message Importance="Normal" Text="Installing node packages for %(PackageJsons.RelativeDir)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
<Exec Command="bun install" WorkingDirectory="%(PackageJsons.RootDir)%(PackageJsons.Directory)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
<Message Importance="Normal" Text="Installing node packages for %(PackageJsons.RelativeDir)"
Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
<Exec Command="bun install" WorkingDirectory="%(PackageJsons.RootDir)%(PackageJsons.Directory)"
Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
</Target>

</Project>
2 changes: 0 additions & 2 deletions src/RookieShop.Application/Extension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using RookieShop.Application.Products.Workers;
using RookieShop.Infrastructure.Cache;
using RookieShop.Infrastructure.Logging;
using RookieShop.Infrastructure.Metrics;
using RookieShop.Infrastructure.Validator;
using RookieShop.Persistence;

Expand Down Expand Up @@ -38,7 +37,6 @@ public static IHostApplicationBuilder AddApplication(this IHostApplicationBuilde
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TxBehavior<,>),
ServiceLifetime.Scoped);
cfg.AddOpenBehavior(typeof(QueryCachingBehavior<,>));
cfg.AddOpenBehavior(typeof(MetricsBehavior<,>));
});

builder.Services.AddHostedService<CalculateRatingWorker>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
using RookieShop.Domain.Entities.ProductAggregator;
using RookieShop.Domain.Entities.ProductAggregator.Primitives;
using RookieShop.Domain.SharedKernel;
using RookieShop.Infrastructure.Ai.Embedded;
using RookieShop.Infrastructure.Storage.Azurite;

namespace RookieShop.Application.Products.Commands.Create;

public sealed class CreateProductHandler(
IRepository<Product> repository,
IAzuriteService azuriteService,
IAiService aiService,
ILogger<CreateProductHandler> logger) : ICommandHandler<CreateProductCommand, Result<ProductId>>
{
public async Task<Result<ProductId>> Handle(CreateProductCommand request, CancellationToken cancellationToken)
Expand All @@ -30,6 +32,8 @@ public async Task<Result<ProductId>> Handle(CreateProductCommand request, Cancel
logger.LogInformation("[{Command}] - Creating product {@Product}", nameof(CreateProductCommand),
JsonSerializer.Serialize(product));

product.Embedding = await aiService.GetEmbeddingAsync($"{product.Name} {product.Description}", cancellationToken);

var result = await repository.AddAsync(product, cancellationToken);

return result.Id;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Ardalis.Result;
using RookieShop.Application.Products.DTOs;
using RookieShop.Domain.Entities.ProductAggregator;
using RookieShop.Domain.Entities.ProductAggregator.Specifications;
using RookieShop.Domain.SharedKernel;
using RookieShop.Infrastructure.Ai.Embedded;
using RookieShop.Infrastructure.Storage.Azurite;

namespace RookieShop.Application.Products.Queries.Search;

public sealed class SearchProductHandler(
IAiService aiService,
IReadRepository<Product> repository,
IAzuriteService azuriteService) : IQueryHandler<SearchProductQuery, PagedResult<IEnumerable<ProductDto>>>
{
public async Task<PagedResult<IEnumerable<ProductDto>>> Handle(SearchProductQuery request,
CancellationToken cancellationToken)
{
var vector = await aiService.GetEmbeddingAsync(request.Context, cancellationToken);

ProductsFilterSpec spec = new(vector, request.PageIndex, request.PageSize);

var products = await repository.ListAsync(spec, cancellationToken);

products.ForEach(p => p.ImageName = azuriteService.GetFileUrl(p.ImageName));

var totalRecords = await repository.CountAsync(spec, cancellationToken);

var totalPages = (int)Math.Ceiling(totalRecords / (double)request.PageSize);

PagedInfo pagedInfo = new(request.PageIndex, request.PageSize, totalPages, totalRecords);

return new(pagedInfo, products.ToProductDto());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Ardalis.Result;
using RookieShop.Application.Products.DTOs;
using RookieShop.Domain.SharedKernel;

namespace RookieShop.Application.Products.Queries.Search;

public sealed record SearchProductQuery(
string Context,
int PageIndex,
int PageSize) : IQuery<PagedResult<IEnumerable<ProductDto>>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;

namespace RookieShop.Application.Products.Queries.Search;

public sealed class SearchProductValidator : AbstractValidator<SearchProductQuery>
{
public SearchProductValidator()
{
RuleFor(x => x.Context).NotEmpty();
RuleFor(x => x.PageIndex).GreaterThanOrEqualTo(1);
RuleFor(x => x.PageSize).GreaterThanOrEqualTo(0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace RookieShop.Domain.Entities.CategoryAggregator;

public sealed class Category : EntityBase, IAggregateRoot
public sealed class Category : EntityBase, ISoftDelete, IAggregateRoot
{
/// <summary>
/// EF mapping constructor
Expand All @@ -23,6 +23,7 @@ public Category(string title, string? description)
public CategoryId Id { get; set; } = new(Guid.NewGuid());
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsDeleted { get; set; }
public ICollection<Product>? Products { get; set; } = [];

public void Update(string name, string? description)
Expand Down
5 changes: 4 additions & 1 deletion src/RookieShop.Domain/Entities/ProductAggregator/Product.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Ardalis.GuardClauses;
using Pgvector;
using System.Text.Json.Serialization;
using Ardalis.GuardClauses;
using RookieShop.Domain.Entities.CategoryAggregator;
using RookieShop.Domain.Entities.CategoryAggregator.Primitives;
using RookieShop.Domain.Entities.FeedbackAggregator;
Expand Down Expand Up @@ -41,6 +43,7 @@ public Product()
public double AverageRating { get; set; }
public int TotalReviews { get; set; }
public CategoryId? CategoryId { get; set; }
[JsonIgnore] public Vector Embedding { get; set; } = default!;
public Category? Category { get; set; }
public ICollection<OrderDetail>? OrderDetails { get; set; } = [];
public ICollection<Feedback>? Feedbacks { get; set; } = [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Ardalis.Specification;
using Pgvector;
using Pgvector.EntityFrameworkCore;
using RookieShop.Domain.Entities.CategoryAggregator.Primitives;

namespace RookieShop.Domain.Entities.ProductAggregator.Specifications;
Expand All @@ -23,4 +25,9 @@ public sealed class ProductsFilterSpec : Specification<Product>
}

public ProductsFilterSpec() => Query.Where(product => !product.IsDeleted);

public ProductsFilterSpec(Vector vector, int pageIndex, int pageSize) =>
Query.Where(product => !product.IsDeleted)
.OrderBy(c => c.Embedding.CosineDistance(vector))
.ApplyPaging(pageIndex, pageSize);
}
5 changes: 5 additions & 0 deletions src/RookieShop.Domain/RookieShop.Domain.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
<PackageReference Include="Ardalis.Result.AspNetCore" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Pgvector" />
<PackageReference Include="Pgvector.EntityFrameworkCore" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="SonarAnalyzer.CSharp">
<PrivateAssets>all</PrivateAssets>
Expand Down
36 changes: 7 additions & 29 deletions src/RookieShop.IdentityService/SeedData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@ public static void EnsureSeedData(WebApplication app)

var roleMgr = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();

var admin = roleMgr.FindByNameAsync("admin").Result;
var staff = roleMgr.FindByNameAsync("staff").Result;

if (admin is null)
if (staff is null)
{
admin = new("admin");
staff = new("admin");

var result = roleMgr.CreateAsync(admin).Result;
var result = roleMgr.CreateAsync(staff).Result;

if (!result.Succeeded) throw new SeedException(result.Errors.First().Description);

roleMgr.AddClaimAsync(admin, new(JwtClaimTypes.Role, AuthScope.Read)).Wait();
roleMgr.AddClaimAsync(admin, new(JwtClaimTypes.Role, AuthScope.Write)).Wait();
roleMgr.AddClaimAsync(admin, new(JwtClaimTypes.Role, AuthScope.All)).Wait();
roleMgr.AddClaimAsync(staff, new(JwtClaimTypes.Role, AuthScope.Read)).Wait();
roleMgr.AddClaimAsync(staff, new(JwtClaimTypes.Role, AuthScope.Write)).Wait();
roleMgr.AddClaimAsync(staff, new(JwtClaimTypes.Role, AuthScope.All)).Wait();

Log.Debug("admin created");
}
Expand All @@ -40,26 +40,6 @@ public static void EnsureSeedData(WebApplication app)
Log.Debug("admin already exists");
}

var user = roleMgr.FindByNameAsync("user").Result;

if (user is null)
{
user = new("user");

var result = roleMgr.CreateAsync(user).Result;

if (!result.Succeeded) throw new SeedException(result.Errors.First().Description);

roleMgr.AddClaimAsync(user, new(JwtClaimTypes.Role, AuthScope.Read)).Wait();
roleMgr.AddClaimAsync(user, new(JwtClaimTypes.Role, AuthScope.Write)).Wait();

Log.Debug("user created");
}
else
{
Log.Debug("user already exists");
}

var userMgr = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();

var nhan = userMgr.FindByNameAsync("nhan").Result;
Expand Down Expand Up @@ -122,8 +102,6 @@ public static void EnsureSeedData(WebApplication app)

if (!result.Succeeded) throw new SeedException(result.Errors.First().Description);

userMgr.AddToRoleAsync(fox, "user").Wait();

Log.Debug("fox created");
}
else
Expand Down
Loading

0 comments on commit a8b08c8

Please sign in to comment.