diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad6b00d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,141 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +> **Cross-repo rules** — see `/Users/wen/project/rag/CLAUDE.md` for full workspace conventions. Key shared rules: +> - File-scoped namespaces, primary constructor DI, `record` for DTOs/Commands/Queries +> - Entity: `BaseEntity + IAuditable/ISoftDelete/IFullAudit` marker interfaces +> - EF Config: snake_case table, `ValueGeneratedNever()`, `IEntityTypeConfiguration` +> - Endpoint: `FastEndpoint` + `Permissions("resource:action")` +> - Middleware: `Cors → GlobalException → ApiResponse → Auth → AuthZ → FastEndpoints` +> - Response: `{ code: 0, data, message: "ok" }` (auto-wrapped) +> - JWT shared key: `RagJwtSecretKey2026MustBeAtLeast32CharsLong!` +> - Other repos: `rag-backend` (5211), `im-system` (5212), `work-flow`, `file-system` (8080 Go), `rag-frontend` (5666 Vue) + +## Build & Run + +```bash +dotnet build +cd src/RAG.Api && dotnet run # HTTP :5211, gRPC :50051 +cd src/RAG.Api && dotnet run -- --seed # Migrate + seed, then exit +cd src/RAG.Infrastructure && dotnet ef migrations add --startup-project ../RAG.Api +cd src/RAG.Infrastructure && dotnet ef database update --startup-project ../RAG.Api +docker compose up -d # PostgreSQL 17 + pgvector, Redis 7, RabbitMQ 3 +``` + +## Architecture + +.NET 10, ABP modular. Four projects: `Api → Application → Infrastructure → Domain`. + +### ABP Module Chain + +``` +RAGApiModule [DependsOn(RAGApplicationModule, RAGInfrastructureModule)] + → RAGApplicationModule [DependsOn(RAGInfrastructureModule)] + → RAGInfrastructureModule [DependsOn(RAGDomainModule)] +``` + +DI in `ConfigureServices()`, middleware in `OnApplicationInitialization()`. `Program.cs` bootstraps via `builder.AddApplicationAsync()`. + +### Middleware Order + +``` +Cors → GlobalExceptionMiddleware → ApiResponseMiddleware → Authentication → Authorization → FastEndpoints → SwaggerGen → MapGrpcService +``` + +### Feature Folder Convention + +``` +Application/{Feature}/ + Commands/{Action}{Entity}Command.cs — record + Handler(DbContext) in same file + Queries/{Action}{Entity}Query.cs — record + Handler(DbContext) in same file + DTOs/{Entity}DTOs.cs — all DTOs for feature in one file + Validators/{Entity}CommandValidators.cs — FluentValidation rules + +Api/Endpoints/{Feature}/ + {Action}{Entity}Endpoint.cs — FastEndpoint +``` + +### gRPC Auth Service + +`AuthGrpcService` (code-first via protobuf-net.Grpc) exposes `ValidateToken` and `CheckPermission`. Other services (file-system, work-flow, im-system) call these RPCs. + +### Seed Data + +`SeedData.cs` creates: 11 permissions, 3 roles (SuperAdmin/Admin/User), admin user (`admin/admin123`), ~13 demo menus. + +## Code Patterns + +### Entity + +```csharp +public class User : BaseEntity, IFullAudit +{ + public string Username { get; set; } = default!; + public ICollection UserRoles { get; set; } = []; + // IAuditable, ISoftDelete, IHasOperatorIP fields with = default! +} +``` + +Join tables: composite key + `IAuditable` only (no BaseEntity, no soft delete). + +### EF Configuration + +```csharp +public class UserConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("users"); // snake_case + builder.HasKey(e => e.Id); + builder.Property(e => e.Id).ValueGeneratedNever(); // Client-generated Guid + builder.HasIndex(e => e.Username).IsUnique(); + } +} +``` + +### Command + Handler (same file) + +```csharp +public record CreateUserCommand(string Username, string Email, string Password) : IRequest; + +public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler +{ + public async Task Handle(CreateUserCommand request, CancellationToken ct) { ... } +} +``` + +### Endpoint + +```csharp +public class CreateUserEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() { Post("/users"); Permissions("user:create"); } + public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) { ... } +} +``` + +### Validation + +```csharp +public class CreateUserCommandValidator : AbstractValidator +{ + public CreateUserCommandValidator() + { + RuleFor(x => x.Username).NotEmpty().Length(3, 50); + } +} +``` + +Auto-registered + `ValidationBehavior` pipeline. Chinese error messages. + +## Conventions + +- File-scoped namespaces everywhere +- Primary constructors for DI (endpoints, handlers, middleware) +- `record` for DTOs/Commands/Queries, `class` for entities/handlers/endpoints +- Nullable reference types ON, `= default!` for required strings +- `TreatWarningsAsErrors` ON +- Permission codes: `{resource}:{action}` (e.g., `user:create`) +- Response: `{ code: 0, data, message: "ok" }` (auto-wrapped) +- Route prefix: `/api/` diff --git a/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs b/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs index 3ca6464..fcafdd3 100644 --- a/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/GetAccessCodesEndpoint.cs @@ -1,5 +1,4 @@ using RAG.Domain.Common; -using RAG.Domain.Exceptions; using FastEndpoints; using MediatR; using RAG.Application.Auth.Queries; diff --git a/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs b/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs index cccde04..f9516a3 100644 --- a/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/GetCurrentUserEndpoint.cs @@ -1,5 +1,4 @@ using RAG.Domain.Common; -using RAG.Domain.Exceptions; using FastEndpoints; using MediatR; using RAG.Application.Auth.Queries; diff --git a/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs b/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs index 3eb7390..705b25d 100644 --- a/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs +++ b/src/RAG.Api/Endpoints/Auth/RefreshTokenEndpoint.cs @@ -1,9 +1,7 @@ using RAG.Domain.Exceptions; using FastEndpoints; using MediatR; -using Microsoft.Extensions.Configuration; using RAG.Application.Auth.Commands; -using RAG.Application.Auth.DTOs; namespace RAG.Api.Endpoints.Auth; diff --git a/src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs b/src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs new file mode 100644 index 0000000..8842ae5 --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Chat.Commands; +using RAG.Application.Chat.DTOs; + +namespace RAG.Api.Endpoints.Chat; + +public class CreateConversationEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/chat/conversations"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CreateConversationRequest req, CancellationToken ct) + { + var result = await mediator.Send(new CreateConversationCommand(req.Title), ct); + await Send.ResponseAsync(result, 201, ct); + } +} + +public record CreateConversationRequest(string Title); diff --git a/src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs b/src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs new file mode 100644 index 0000000..5977e92 --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Chat.Commands; + +namespace RAG.Api.Endpoints.Chat; + +public class DeleteConversationEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Delete("/chat/conversations/{Id}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(DeleteConversationEndpointRequest req, CancellationToken ct) + { + await mediator.Send(new DeleteConversationCommand(req.Id), ct); + HttpContext.Response.StatusCode = 204; + } +} + +public record DeleteConversationEndpointRequest(Guid Id); diff --git a/src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs b/src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs new file mode 100644 index 0000000..1e005fc --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Chat.DTOs; +using RAG.Application.Chat.Queries; + +namespace RAG.Api.Endpoints.Chat; + +public class GetConversationDetailEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Get("/chat/conversations/{Id}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(GetConversationDetailRequest req, CancellationToken ct) + { + var result = await mediator.Send(new GetConversationDetailQuery(req.Id), ct); + await Send.OkAsync(result, ct); + } +} + +public record GetConversationDetailRequest(Guid Id); diff --git a/src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs b/src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs new file mode 100644 index 0000000..4fe39dc --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs @@ -0,0 +1,21 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Chat.DTOs; +using RAG.Application.Chat.Queries; + +namespace RAG.Api.Endpoints.Chat; + +public class GetConversationsEndpoint(IMediator mediator) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/chat/conversations"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await mediator.Send(new GetConversationsQuery(), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs b/src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs new file mode 100644 index 0000000..2d9e56c --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs @@ -0,0 +1,24 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Chat.Commands; +using RAG.Application.Chat.DTOs; + +namespace RAG.Api.Endpoints.Chat; + +public class SendMessageEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/chat/conversations/{ConversationId}/messages"); + AllowAnonymous(); + } + + public override async Task HandleAsync(SendMessageRequest req, CancellationToken ct) + { + var conversationId = Route("ConversationId"); + var result = await mediator.Send(new SendMessageCommand(conversationId, req.Content), ct); + await Send.OkAsync(result, ct); + } +} + +public record SendMessageRequest(string Content); diff --git a/src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs b/src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs new file mode 100644 index 0000000..22f5f03 --- /dev/null +++ b/src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs @@ -0,0 +1,79 @@ +using System.Text; +using System.Text.Json; +using FastEndpoints; +using RAG.Domain.Entities; +using RAG.Domain.Enums; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Api.Endpoints.Chat; + +public class StreamMessageEndpoint(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache) + : Endpoint +{ + public override void Configure() + { + Post("/chat/conversations/{ConversationId}/stream"); + AllowAnonymous(); + } + + public override async Task HandleAsync(StreamMessageRequest req, CancellationToken ct) + { + var conversationId = Route("ConversationId"); + + var conversation = await db.Conversations.FindAsync([conversationId], ct); + if (conversation is null) + { + HttpContext.Response.StatusCode = 404; + return; + } + + // 保存用户消息到 DB + Redis + var userMessage = new ChatMessage + { + ConversationId = conversationId, + Role = ChatRole.User, + Content = req.Content + }; + await db.ChatMessages.AddAsync(userMessage, ct); + await db.SaveChangesAsync(ct); + await cache.AppendMessageAsync(conversationId, + new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct); + + // SSE 响应 + HttpContext.Response.ContentType = "text/event-stream"; + HttpContext.Response.Headers.CacheControl = "no-cache"; + HttpContext.Response.Headers.Connection = "keep-alive"; + + var fullReply = new StringBuilder(); + + await foreach (var chunk in chatAgent.RunStreamingAsync(req.Content, ct)) + { + fullReply.Append(chunk); + var sseData = JsonSerializer.Serialize(new { content = chunk }); + await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + } + + var replyText = fullReply.ToString(); + + // 保存助手回复到 DB + Redis + var assistantMessage = new ChatMessage + { + ConversationId = conversationId, + Role = ChatRole.Assistant, + Content = replyText + }; + await db.ChatMessages.AddAsync(assistantMessage, ct); + await db.SaveChangesAsync(ct); + await cache.AppendMessageAsync(conversationId, + new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct); + + // 发送结束标记(含完整消息 ID) + var doneData = JsonSerializer.Serialize(new { messageId = assistantMessage.Id }); + await HttpContext.Response.WriteAsync($"event: done\ndata: {doneData}\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + } +} + +public record StreamMessageRequest(string Content); diff --git a/src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs b/src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs new file mode 100644 index 0000000..cdd6178 --- /dev/null +++ b/src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using FastEndpoints; +using MediatR; +using RAG.Application.Document.Commands; +using RAG.Application.Document.DTOs; + +namespace RAG.Api.Endpoints.Document; + +// ===== 数据模型 ===== + +public class ChunkUploadSession(string sessionId, Guid knowledgeBaseId, string fileName, long fileSize, int chunkCount, string tempDir) +{ + public string SessionId { get; } = sessionId; + public Guid KnowledgeBaseId { get; } = knowledgeBaseId; + public string FileName { get; } = fileName; + public long FileSize { get; } = fileSize; + public int ChunkCount { get; } = chunkCount; + public string TempDir { get; } = tempDir; + public ConcurrentDictionary UploadedChunks { get; } = []; +} + +public static class ChunkUploadStore +{ + public static readonly ConcurrentDictionary Sessions = []; +} + +// ===== 初始化 ===== + +public class InitChunkUploadRequest +{ + public string FileName { get; set; } = default!; + public long FileSize { get; set; } + public int ChunkCount { get; set; } +} + +public class InitChunkUploadEndpoint : Endpoint +{ + public override void Configure() + { + Post("/knowledge-bases/{KnowledgeBaseId}/documents/chunk-upload/init"); + AllowAnonymous(); + } + + public override async Task HandleAsync(InitChunkUploadRequest req, CancellationToken ct) + { + var kbId = Route("KnowledgeBaseId"); + var sessionId = Guid.NewGuid().ToString("N"); + var tempDir = Path.Combine(Path.GetTempPath(), "rag_chunks", sessionId); + Directory.CreateDirectory(tempDir); + + var session = new ChunkUploadSession(sessionId, kbId, req.FileName, req.FileSize, req.ChunkCount, tempDir); + ChunkUploadStore.Sessions[sessionId] = session; + + await Send.OkAsync(session, ct); + } +} + +// ===== 上传分片 ===== + +public class UploadChunkEndpoint : EndpointWithoutRequest +{ + public override void Configure() + { + Post("/documents/chunk-upload/{SessionId}/chunks/{ChunkIndex}"); + AllowAnonymous(); + AllowFileUploads(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var sessionId = Route("SessionId"); + var chunkIndex = Route("ChunkIndex"); + + if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session)) + { + HttpContext.Response.StatusCode = 404; + return; + } + + var file = Files.FirstOrDefault(); + if (file is null) + { + ThrowError("请上传分片文件"); + return; + } + + var chunkPath = Path.Combine(session.TempDir, $"chunk_{chunkIndex}"); + await using (var stream = System.IO.File.Create(chunkPath)) + { + await file.CopyToAsync(stream, ct); + } + + session.UploadedChunks.TryAdd(chunkIndex, chunkPath); + HttpContext.Response.StatusCode = 200; + await HttpContext.Response.WriteAsJsonAsync(new { uploaded = session.UploadedChunks.Count, total = session.ChunkCount }, ct); + } +} + +// ===== 合并完成 ===== + +public class CompleteChunkUploadRequest +{ + public string? Title { get; set; } +} + +public class CompleteChunkUploadEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/documents/chunk-upload/{SessionId}/complete"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CompleteChunkUploadRequest req, CancellationToken ct) + { + var sessionId = Route("SessionId"); + + if (!ChunkUploadStore.Sessions.TryGetValue(sessionId!, out var session)) + { + HttpContext.Response.StatusCode = 404; + return; + } + + if (session.UploadedChunks.Count != session.ChunkCount) + { + ThrowError($"分片不完整:已上传 {session.UploadedChunks.Count}/{session.ChunkCount}"); + return; + } + + var ext = Path.GetExtension(session.FileName); + var mergedPath = Path.Combine(Path.GetTempPath(), $"rag_{Guid.NewGuid()}{ext}"); + await using (var mergedStream = System.IO.File.Create(mergedPath)) + { + for (var i = 0; i < session.ChunkCount; i++) + { + if (!session.UploadedChunks.TryGetValue(i, out var chunkPath)) continue; + await using var chunkStream = System.IO.File.OpenRead(chunkPath); + await chunkStream.CopyToAsync(mergedStream, ct); + } + } + + var result = await mediator.Send(new UploadDocumentCommand( + session.KnowledgeBaseId, + req.Title ?? session.FileName, + session.FileName, + mergedPath, + session.FileSize, + "application/octet-stream" + ), ct); + + try { Directory.Delete(session.TempDir, true); } catch { /* ignore */ } + ChunkUploadStore.Sessions.TryRemove(sessionId!, out _); + + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs b/src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs new file mode 100644 index 0000000..cc4cba4 --- /dev/null +++ b/src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Document.DTOs; +using RAG.Application.Document.Queries; + +namespace RAG.Api.Endpoints.Document; + +public class GetDocumentChunksEndpoint(IMediator mediator) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/documents/{DocumentId}/chunks"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var docId = Route("DocumentId"); + var result = await mediator.Send(new GetDocumentChunksQuery(docId), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs b/src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs new file mode 100644 index 0000000..996d628 --- /dev/null +++ b/src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Document.DTOs; +using RAG.Application.Document.Queries; + +namespace RAG.Api.Endpoints.Document; + +public class ListDocumentsEndpoint(IMediator mediator) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/knowledge-bases/{KnowledgeBaseId}/documents"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var kbId = Route("KnowledgeBaseId"); + var result = await mediator.Send(new GetDocumentsQuery(kbId), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs b/src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs new file mode 100644 index 0000000..3e671da --- /dev/null +++ b/src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Document.Commands; +using RAG.Application.Document.DTOs; + +namespace RAG.Api.Endpoints.Document; + +public class ProcessDocumentEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/documents/{DocumentId}/process"); + AllowAnonymous(); + } + + public override async Task HandleAsync(EmptyRequest req, CancellationToken ct) + { + var docId = Route("DocumentId"); + var result = await mediator.Send(new ProcessDocumentCommand(docId), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs b/src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs new file mode 100644 index 0000000..1aae62f --- /dev/null +++ b/src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs @@ -0,0 +1,38 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Document.Commands; +using RAG.Application.Document.DTOs; +using DomainExceptions = RAG.Domain.Exceptions; + +namespace RAG.Api.Endpoints.Document; + +public class UploadDocumentEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/knowledge-bases/{KnowledgeBaseId}/documents"); + AllowAnonymous(); + AllowFileUploads(); + } + + public override async Task HandleAsync(UploadDocumentRequest req, CancellationToken ct) + { + var kbId = Route("KnowledgeBaseId"); + var file = Files.FirstOrDefault(); + if (file is null) + throw new DomainExceptions.BusinessException("请上传文件"); + + var tempPath = Path.Combine(Path.GetTempPath(), $"rag_{Guid.NewGuid()}{Path.GetExtension(file.FileName)}"); + await using (var stream = System.IO.File.Create(tempPath)) + { + await file.CopyToAsync(stream, ct); + } + + var result = await mediator.Send(new UploadDocumentCommand(kbId, req.Title ?? file.FileName, + file.FileName, tempPath, new FileInfo(tempPath).Length, file.ContentType), ct); + + await Send.ResponseAsync(result, 201, ct); + } +} + +public record UploadDocumentRequest(string? Title); diff --git a/src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs b/src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs new file mode 100644 index 0000000..28e5e6b --- /dev/null +++ b/src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Embedding.Commands; +using RAG.Application.Embedding.DTOs; + +namespace RAG.Api.Endpoints.Embedding; + +public class EmbedBatchEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/embeddings/batch"); + AllowAnonymous(); + } + + public override async Task HandleAsync(EmbedBatchRequest req, CancellationToken ct) + { + var result = await mediator.Send(new EmbedBatchCommand(req.Texts), ct); + await Send.OkAsync(result, ct); + } +} + +public record EmbedBatchRequest(List Texts); diff --git a/src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs b/src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs new file mode 100644 index 0000000..9bc0647 --- /dev/null +++ b/src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs @@ -0,0 +1,23 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.Embedding.Commands; +using RAG.Application.Embedding.DTOs; + +namespace RAG.Api.Endpoints.Embedding; + +public class EmbedTextEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/embeddings"); + AllowAnonymous(); + } + + public override async Task HandleAsync(EmbedTextRequest req, CancellationToken ct) + { + var result = await mediator.Send(new EmbedTextCommand(req.Text), ct); + await Send.OkAsync(result, ct); + } +} + +public record EmbedTextRequest(string Text); diff --git a/src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs b/src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs new file mode 100644 index 0000000..e9eb38e --- /dev/null +++ b/src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.KnowledgeBase.Commands; +using RAG.Application.KnowledgeBase.DTOs; + +namespace RAG.Api.Endpoints.KnowledgeBase; + +public class CreateKBEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/knowledge-bases"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CreateKnowledgeBaseRequest req, CancellationToken ct) + { + var result = await mediator.Send( + new CreateKnowledgeBaseCommand(req.Name, req.Description, req.EmbeddingModel, req.ChunkSize, req.ChunkOverlap), ct); + await Send.ResponseAsync(result, 201, ct); + } +} diff --git a/src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs b/src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs new file mode 100644 index 0000000..ebd8a01 --- /dev/null +++ b/src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs @@ -0,0 +1,22 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.KnowledgeBase.Commands; + +namespace RAG.Api.Endpoints.KnowledgeBase; + +public class DeleteKBEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Delete("/knowledge-bases/{Id}"); + AllowAnonymous(); + } + + public override async Task HandleAsync(DeleteKBEndpointRequest req, CancellationToken ct) + { + await mediator.Send(new DeleteKnowledgeBaseCommand(req.Id), ct); + HttpContext.Response.StatusCode = 204; + } +} + +public record DeleteKBEndpointRequest(Guid Id); diff --git a/src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs b/src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs new file mode 100644 index 0000000..803433a --- /dev/null +++ b/src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs @@ -0,0 +1,21 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.KnowledgeBase.DTOs; +using RAG.Application.KnowledgeBase.Queries; + +namespace RAG.Api.Endpoints.KnowledgeBase; + +public class GetKBsEndpoint(IMediator mediator) : EndpointWithoutRequest> +{ + public override void Configure() + { + Get("/knowledge-bases"); + AllowAnonymous(); + } + + public override async Task HandleAsync(CancellationToken ct) + { + var result = await mediator.Send(new GetKnowledgeBasesQuery(), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs b/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs index 42f7d72..1604d5b 100644 --- a/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs +++ b/src/RAG.Api/Endpoints/Menus/GetAllMenusEndpoint.cs @@ -1,5 +1,4 @@ using RAG.Domain.Common; -using RAG.Domain.Exceptions; using FastEndpoints; using MediatR; using RAG.Application.Menus.DTOs; diff --git a/src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs b/src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs new file mode 100644 index 0000000..4382dae --- /dev/null +++ b/src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs @@ -0,0 +1,21 @@ +using FastEndpoints; +using MediatR; +using RAG.Application.RagQA.Commands; +using RAG.Application.RagQA.DTOs; + +namespace RAG.Api.Endpoints.RAG; + +public class RAGQueryEndpoint(IMediator mediator) : Endpoint +{ + public override void Configure() + { + Post("/rag/query"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct) + { + var result = await mediator.Send(new RAGQueryCommand(req.KnowledgeBaseId, req.Question), ct); + await Send.OkAsync(result, ct); + } +} diff --git a/src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs b/src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs new file mode 100644 index 0000000..fb7a0a3 --- /dev/null +++ b/src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs @@ -0,0 +1,169 @@ +using System.Text; +using System.Text.Json; +using FastEndpoints; +using Microsoft.EntityFrameworkCore; +using RAG.Application.RagQA.DTOs; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Api.Endpoints.RAG; + +public class RAGStreamEndpoint(RagDbContext db, IEmbeddingService embeddingService, IAIChatAgent chatAgent) + : Endpoint +{ + private const int VectorTopK = 20; + private const int FinalTopK = 5; + private const double MinSimilarity = 0.3; + + public override void Configure() + { + Post("/rag/stream"); + AllowAnonymous(); + } + + public override async Task HandleAsync(RAGQueryRequest req, CancellationToken ct) + { + var queryVector = await embeddingService.EmbedAsync(req.Question, ct); + var vectorStr = $"[{string.Join(",", queryVector)}]"; + + var vectorSql = $""" + SELECT dc."Content", d."Title" as document_title, dc."DocumentId", + 1 - (dc.embedding <=> '{vectorStr}'::vector) AS similarity + FROM document_chunks dc + JOIN documents d ON dc."DocumentId" = d."Id" + WHERE d."KnowledgeBaseId" = '{req.KnowledgeBaseId}' + AND dc.embedding IS NOT NULL + ORDER BY dc.embedding <=> '{vectorStr}'::vector + LIMIT {VectorTopK} + """; + + var candidates = new List<(Guid DocId, string DocTitle, string Content, double VectorScore)>(); + var conn = db.Database.GetDbConnection(); + await conn.OpenAsync(ct); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = vectorSql; + using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var similarity = reader["similarity"] is double d ? d : 0.0; + if (similarity < MinSimilarity) continue; + candidates.Add(( + reader["DocumentId"] is Guid g ? g : Guid.Empty, + reader["document_title"]?.ToString() ?? "", + reader["Content"]?.ToString() ?? "", + similarity + )); + } + } + finally + { + await conn.CloseAsync(); + } + + // SSE + HttpContext.Response.ContentType = "text/event-stream"; + HttpContext.Response.Headers.CacheControl = "no-cache"; + HttpContext.Response.Headers.Connection = "keep-alive"; + + if (candidates.Count == 0) + { + var noData = JsonSerializer.Serialize(new { content = "未在知识库中找到相关内容。" }); + await HttpContext.Response.WriteAsync($"data: {noData}\n\n", ct); + await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + return; + } + + // 关键词重排序 + var keywords = ExtractKeywords(req.Question); + var ranked = candidates.Select(c => + { + var keywordScore = KeywordMatchScore(c.Content, keywords); + var finalScore = c.VectorScore * 0.7 + keywordScore * 0.3; + return (c.DocId, c.DocTitle, c.Content, FinalScore: finalScore); + }) + .OrderByDescending(x => x.FinalScore) + .Take(FinalTopK) + .ToList(); + + var sources = ranked.Select(r => new SourceChunk( + r.DocId, + r.DocTitle, + r.Content.Length > 200 ? r.Content[..200] + "..." : r.Content, + Math.Round(r.FinalScore, 4) + )).ToList(); + + var sourcesData = JsonSerializer.Serialize(new { sources }); + await HttpContext.Response.WriteAsync($"event: sources\ndata: {sourcesData}\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + + var contextBuilder = new StringBuilder(); + contextBuilder.AppendLine("以下是检索到的相关上下文:"); + for (var i = 0; i < ranked.Count; i++) + contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{ranked[i].Content}"); + + var prompt = $""" + {contextBuilder} + + 基于以上上下文,请回答以下问题。如果上下文中没有相关信息,请如实说明。 + + 问题:{req.Question} + """; + + await foreach (var chunk in chatAgent.RunStreamingAsync(prompt, ct)) + { + var sseData = JsonSerializer.Serialize(new { content = chunk }); + await HttpContext.Response.WriteAsync($"data: {sseData}\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + } + + await HttpContext.Response.WriteAsync("data: [DONE]\n\n", ct); + await HttpContext.Response.Body.FlushAsync(ct); + } + + private static List ExtractKeywords(string question) + { + var stopWords = new HashSet + { + "的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都", + "这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪", + "哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会", + "what", "how", "why", "is", "the", "a", "an", "of", "to", "in" + }; + + var words = new List(); + var englishWords = System.Text.RegularExpressions.Regex.Matches(question, @"[a-zA-Z]+") + .Select(m => m.Value.ToLowerInvariant()) + .Where(w => w.Length >= 2 && !stopWords.Contains(w)); + words.AddRange(englishWords); + + var chineseSegments = System.Text.RegularExpressions.Regex.Matches(question, @"[一-鿿]+"); + foreach (System.Text.RegularExpressions.Match seg in chineseSegments) + { + var text = seg.Value; + if (text.Length <= 4) + { + if (!stopWords.Contains(text)) words.Add(text); + } + else + { + for (var len = 2; len <= Math.Min(4, text.Length); len++) + for (var i = 0; i <= text.Length - len; i++) + { + var sub = text[i..(i + len)]; + if (!stopWords.Contains(sub)) words.Add(sub); + } + } + } + return words.Distinct().ToList(); + } + + private static double KeywordMatchScore(string content, List keywords) + { + if (keywords.Count == 0) return 0; + var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase)); + return (double)matched / keywords.Count; + } +} diff --git a/src/RAG.Api/Grpc/AuthGrpcService.cs b/src/RAG.Api/Grpc/AuthGrpcService.cs index 6e19ad6..505244b 100644 --- a/src/RAG.Api/Grpc/AuthGrpcService.cs +++ b/src/RAG.Api/Grpc/AuthGrpcService.cs @@ -1,13 +1,15 @@ using Grpc.Core; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.IdentityModel.Tokens; +using RAG.Infrastructure.Persistence; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; namespace RAG.Api.Grpc; -public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBase +public class AuthGrpcService(IConfiguration config, IServiceScopeFactory scopeFactory) : AuthService.AuthServiceBase { private readonly TokenValidationParameters _validationParams = new() { @@ -38,7 +40,7 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas ?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub) ?? ""; response.Username = principal.FindFirstValue(ClaimTypes.Name) ?? principal.FindFirstValue(JwtRegisteredClaimNames.UniqueName) ?? ""; - response.Email = principal.FindFirstValue(JwtRegisteredClaimNames.Email) + response.Email = principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue(ClaimTypes.Email) ?? ""; response.Roles.AddRange(principal.FindAll(ClaimTypes.Role).Select(c => c.Value)); response.Permissions.AddRange(principal.FindAll("permissions").Select(c => c.Value)); @@ -70,4 +72,40 @@ public class AuthGrpcService(IConfiguration config) : AuthService.AuthServiceBas return Task.FromResult(response); } + + public override async Task GetUsers(GetUsersRequest request, ServerCallContext context) + { + var response = new GetUsersResponse(); + + if (request.UserIds.Count == 0) + return response; + + var userIds = request.UserIds + .Select(id => Guid.TryParse(id, out var guid) ? guid : Guid.Empty) + .Where(id => id != Guid.Empty) + .ToList(); + + if (userIds.Count == 0) + return response; + + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var users = await db.Users + .Where(u => userIds.Contains(u.Id)) + .Select(u => new { u.Id, u.Username }) + .ToListAsync(context.CancellationToken); + + foreach (var user in users) + { + response.Users.Add(new UserInfo + { + UserId = user.Id.ToString(), + Username = user.Username, + Avatar = "" + }); + } + + return response; + } } diff --git a/src/RAG.Api/Middleware/ApiResponseMiddleware.cs b/src/RAG.Api/Middleware/ApiResponseMiddleware.cs index c863506..61d730e 100644 --- a/src/RAG.Api/Middleware/ApiResponseMiddleware.cs +++ b/src/RAG.Api/Middleware/ApiResponseMiddleware.cs @@ -16,7 +16,9 @@ public class ApiResponseMiddleware(RequestDelegate next) public async Task InvokeAsync(HttpContext context) { - if (context.Request.ContentType?.StartsWith("application/grpc") == true || IsExcludedPath(context.Request.Path)) + if (context.Request.ContentType?.StartsWith("application/grpc") == true + || IsExcludedPath(context.Request.Path) + || IsStreamEndpoint(context.Request.Path)) { await next(context); return; @@ -77,5 +79,10 @@ public class ApiResponseMiddleware(RequestDelegate next) private static bool IsExcludedPath(PathString path) => ExcludedPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true); + private static readonly string[] StreamPaths = ["/api/rag/stream", "/api/chat/"]; + private static bool IsStreamEndpoint(PathString path) => + StreamPaths.Any(p => path.Value?.StartsWith(p, StringComparison.OrdinalIgnoreCase) == true + && path.Value?.EndsWith("/stream", StringComparison.OrdinalIgnoreCase) == true); + private record ApiResponse(JsonElement Data, int Code = 0, string Message = "ok"); } diff --git a/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs b/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs index 91ec387..007c028 100644 --- a/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs +++ b/src/RAG.Api/Middleware/GlobalExceptionMiddleware.cs @@ -6,10 +6,6 @@ using RAG.Domain.Exceptions; namespace RAG.Api.Middleware; -/// -/// 全局异常中间件,捕获所有未处理异常并统一返回 { code, message, data } 格式。 -/// 必须注册在 ApiResponseMiddleware 之前,确保异常不会穿透到 ASP.NET Core 默认错误页。 -/// public class GlobalExceptionMiddleware(RequestDelegate next) { private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; @@ -28,6 +24,7 @@ public class GlobalExceptionMiddleware(RequestDelegate next) } catch (Exception ex) { + if (context.Response.HasStarted) return; await HandleExceptionAsync(context, ex); } } diff --git a/src/RAG.Api/Protos/auth.proto b/src/RAG.Api/Protos/auth.proto index b8b96da..5f6de5e 100644 --- a/src/RAG.Api/Protos/auth.proto +++ b/src/RAG.Api/Protos/auth.proto @@ -7,6 +7,7 @@ option csharp_namespace = "RAG.Api.Grpc"; service AuthService { rpc ValidateToken (ValidateTokenRequest) returns (ValidateTokenResponse); rpc CheckPermission (CheckPermissionRequest) returns (CheckPermissionResponse); + rpc GetUsers (GetUsersRequest) returns (GetUsersResponse); } message ValidateTokenRequest { @@ -33,3 +34,17 @@ message CheckPermissionResponse { string user_id = 2; repeated string roles = 3; } + +message GetUsersRequest { + repeated string user_ids = 1; +} + +message GetUsersResponse { + repeated UserInfo users = 1; +} + +message UserInfo { + string user_id = 1; + string username = 2; + string avatar = 3; +} diff --git a/src/RAG.Api/appsettings.json b/src/RAG.Api/appsettings.json index 0e71556..f6190d0 100644 --- a/src/RAG.Api/appsettings.json +++ b/src/RAG.Api/appsettings.json @@ -19,14 +19,14 @@ }, "AllowedHosts": "*", "ConnectionStrings": { - "Default": "Host=192.168.1.154;Port=5432;Database=rag;Username=auto_agent;Password=auto_agent", - "Redis": "192.168.1.154:31040,password=xn001624." + "Default": "Host=localhost;Port=5432;Database=rag;Username=rag;Password=rag123", + "Redis": "localhost:6379,abortConnect=false" }, "RabbitMq": { - "Host": "192.168.1.154", - "Port": 31020, + "Host": "localhost", + "Port": 5672, "Username": "guest", - "Password": "xn001624." + "Password": "guest" }, "Jwt": { "SigningKey": "RagJwtSecretKey2026MustBeAtLeast32CharsLong!", @@ -35,5 +35,14 @@ }, "Cookie": { "Secure": false + }, + "Ai": { + "BaseUrl": "http://localhost:1234/v1", + "ApiKey": "lm-studio", + "ChatModel": "qwen/qwen3.6-27b", + "EmbeddingModel": "text-embedding-embeddinggemma-300m", + "DefaultInstructions": "你是一个RAG知识库助手,基于提供的上下文准确回答用户问题。如果上下文中没有相关信息,请如实说明。", + "MaxTokens": 2000, + "Temperature": 0.7 } } diff --git a/src/RAG.Application/Chat/Commands/CreateConversationCommand.cs b/src/RAG.Application/Chat/Commands/CreateConversationCommand.cs new file mode 100644 index 0000000..00fb547 --- /dev/null +++ b/src/RAG.Application/Chat/Commands/CreateConversationCommand.cs @@ -0,0 +1,28 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Chat.DTOs; +using RAG.Domain.Common; +using RAG.Domain.Entities; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Chat.Commands; + +public record CreateConversationCommand(string Title) : IRequest; + +public class CreateConversationCommandHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler +{ + public async Task Handle(CreateConversationCommand request, CancellationToken ct) + { + var conversation = new Conversation + { + Title = request.Title, + UserId = Guid.Parse(userContext.GetRequiredUserId()) + }; + + await db.Conversations.AddAsync(conversation, ct); + await db.SaveChangesAsync(ct); + + return new ConversationDto(conversation.Id, conversation.Title, conversation.UserId, + conversation.KnowledgeBaseId, conversation.CreatedAt); + } +} diff --git a/src/RAG.Application/Chat/Commands/DeleteConversationCommand.cs b/src/RAG.Application/Chat/Commands/DeleteConversationCommand.cs new file mode 100644 index 0000000..6fd8034 --- /dev/null +++ b/src/RAG.Application/Chat/Commands/DeleteConversationCommand.cs @@ -0,0 +1,24 @@ +using MediatR; +using RAG.Domain.Exceptions; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Chat.Commands; + +public record DeleteConversationCommand(Guid Id) : IRequest; + +public class DeleteConversationCommandHandler(RagDbContext db, IChatMessageCache cache) + : IRequestHandler +{ + public async Task Handle(DeleteConversationCommand request, CancellationToken ct) + { + var conversation = await db.Conversations.FindAsync([request.Id], ct) + ?? throw new NotFoundException("会话不存在"); + + db.Conversations.Remove(conversation); + await db.SaveChangesAsync(ct); + await cache.RemoveAsync(request.Id, ct); + + return Unit.Value; + } +} diff --git a/src/RAG.Application/Chat/Commands/SendMessageCommand.cs b/src/RAG.Application/Chat/Commands/SendMessageCommand.cs new file mode 100644 index 0000000..f10bc39 --- /dev/null +++ b/src/RAG.Application/Chat/Commands/SendMessageCommand.cs @@ -0,0 +1,62 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Chat.DTOs; +using RAG.Domain.Entities; +using RAG.Domain.Enums; +using RAG.Domain.Exceptions; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Chat.Commands; + +public record SendMessageCommand(Guid ConversationId, string Content) : IRequest; + +public class SendMessageCommandHandler(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache) + : IRequestHandler +{ + public async Task Handle(SendMessageCommand request, CancellationToken ct) + { + var conversation = await db.Conversations.FindAsync([request.ConversationId], ct) + ?? throw new NotFoundException("会话不存在"); + + // 保存用户消息 + var userMessage = new ChatMessage + { + ConversationId = request.ConversationId, + Role = ChatRole.User, + Content = request.Content + }; + await db.ChatMessages.AddAsync(userMessage, ct); + await db.SaveChangesAsync(ct); + + // 追加到 Redis + await cache.AppendMessageAsync(request.ConversationId, + new CachedChatMessage(userMessage.Id, ChatRole.User.ToString(), userMessage.Content, null, userMessage.CreatedAt), ct); + + // 获取历史消息构建上下文 + var history = await db.ChatMessages + .Where(m => m.ConversationId == request.ConversationId) + .OrderBy(m => m.CreatedAt) + .Select(m => $"{m.Role}: {m.Content}") + .ToListAsync(ct); + + var prompt = string.Join("\n", history); + var reply = await chatAgent.RunAsync(prompt, ct); + + // 保存助手回复 + var assistantMessage = new ChatMessage + { + ConversationId = request.ConversationId, + Role = ChatRole.Assistant, + Content = reply + }; + await db.ChatMessages.AddAsync(assistantMessage, ct); + await db.SaveChangesAsync(ct); + + // 追加到 Redis + await cache.AppendMessageAsync(request.ConversationId, + new CachedChatMessage(assistantMessage.Id, ChatRole.Assistant.ToString(), assistantMessage.Content, null, assistantMessage.CreatedAt), ct); + + return new SendMessageResponse(assistantMessage.Id, reply); + } +} diff --git a/src/RAG.Application/Chat/DTOs/ChatDTOs.cs b/src/RAG.Application/Chat/DTOs/ChatDTOs.cs new file mode 100644 index 0000000..74a30ca --- /dev/null +++ b/src/RAG.Application/Chat/DTOs/ChatDTOs.cs @@ -0,0 +1,10 @@ +namespace RAG.Application.Chat.DTOs; + +public record ConversationDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId, DateTime CreatedAt); + +public record ConversationDetailDto(Guid Id, string Title, Guid UserId, Guid? KnowledgeBaseId, + DateTime CreatedAt, List Messages); + +public record ChatMessageDto(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt); + +public record SendMessageResponse(Guid MessageId, string Content); diff --git a/src/RAG.Application/Chat/Queries/GetConversationDetailQuery.cs b/src/RAG.Application/Chat/Queries/GetConversationDetailQuery.cs new file mode 100644 index 0000000..2e90428 --- /dev/null +++ b/src/RAG.Application/Chat/Queries/GetConversationDetailQuery.cs @@ -0,0 +1,43 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Chat.DTOs; +using RAG.Domain.Exceptions; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Chat.Queries; + +public record GetConversationDetailQuery(Guid Id) : IRequest; + +public class GetConversationDetailQueryHandler(RagDbContext db, IChatMessageCache cache) + : IRequestHandler +{ + public async Task Handle(GetConversationDetailQuery request, CancellationToken ct) + { + var conversation = await db.Conversations.FindAsync([request.Id], ct) + ?? throw new NotFoundException("会话不存在"); + + // 先查 Redis 缓存 + var cached = await cache.GetMessagesAsync(request.Id, ct); + if (cached is not null) + { + var messages = cached.Select(m => new ChatMessageDto(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList(); + return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId, + conversation.KnowledgeBaseId, conversation.CreatedAt, messages); + } + + // 缓存 miss,查数据库 + var dbMessages = await db.ChatMessages + .Where(m => m.ConversationId == request.Id) + .OrderBy(m => m.CreatedAt) + .Select(m => new ChatMessageDto(m.Id, m.Role.ToString(), m.Content, m.TokenUsage, m.CreatedAt)) + .ToListAsync(ct); + + // 回填 Redis + var cachedMessages = dbMessages.Select(m => new CachedChatMessage(m.Id, m.Role, m.Content, m.TokenUsage, m.CreatedAt)).ToList(); + await cache.SetMessagesAsync(request.Id, cachedMessages, ct); + + return new ConversationDetailDto(conversation.Id, conversation.Title, conversation.UserId, + conversation.KnowledgeBaseId, conversation.CreatedAt, dbMessages); + } +} diff --git a/src/RAG.Application/Chat/Queries/GetConversationsQuery.cs b/src/RAG.Application/Chat/Queries/GetConversationsQuery.cs new file mode 100644 index 0000000..72cbc80 --- /dev/null +++ b/src/RAG.Application/Chat/Queries/GetConversationsQuery.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Chat.DTOs; +using RAG.Domain.Common; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Chat.Queries; + +public record GetConversationsQuery : IRequest>; + +public class GetConversationsQueryHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler> +{ + public async Task> Handle(GetConversationsQuery request, CancellationToken ct) + { + var userId = Guid.Parse(userContext.GetRequiredUserId()); + return await db.Conversations + .Where(c => c.UserId == userId) + .OrderByDescending(c => c.CreatedAt) + .Select(c => new ConversationDto(c.Id, c.Title, c.UserId, c.KnowledgeBaseId, c.CreatedAt)) + .ToListAsync(ct); + } +} diff --git a/src/RAG.Application/Chat/Validators/ChatValidators.cs b/src/RAG.Application/Chat/Validators/ChatValidators.cs new file mode 100644 index 0000000..bafd04e --- /dev/null +++ b/src/RAG.Application/Chat/Validators/ChatValidators.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace RAG.Application.Chat.Validators; + +public class CreateConversationCommandValidator : AbstractValidator +{ + public CreateConversationCommandValidator() + { + RuleFor(x => x.Title).NotEmpty().WithMessage("会话标题不能为空") + .MaximumLength(200).WithMessage("会话标题不能超过200个字符"); + } +} + +public class SendMessageCommandValidator : AbstractValidator +{ + public SendMessageCommandValidator() + { + RuleFor(x => x.ConversationId).NotEmpty().WithMessage("会话ID不能为空"); + RuleFor(x => x.Content).NotEmpty().WithMessage("消息内容不能为空") + .MaximumLength(10000).WithMessage("消息内容不能超过10000个字符"); + } +} diff --git a/src/RAG.Application/Document/Commands/ProcessDocumentCommand.cs b/src/RAG.Application/Document/Commands/ProcessDocumentCommand.cs new file mode 100644 index 0000000..a00c08d --- /dev/null +++ b/src/RAG.Application/Document/Commands/ProcessDocumentCommand.cs @@ -0,0 +1,88 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Document.DTOs; +using RAG.Domain.Entities; +using RAG.Domain.Enums; +using RAG.Domain.Exceptions; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Document.Commands; + +public record ProcessDocumentCommand(Guid DocumentId) : IRequest; + +public class ProcessDocumentCommandHandler(RagDbContext db, ITextChunker chunker, IEmbeddingService embeddingService) + : IRequestHandler +{ + public async Task Handle(ProcessDocumentCommand request, CancellationToken ct) + { + var doc = await db.Documents.FindAsync([request.DocumentId], ct) + ?? throw new NotFoundException("文档不存在"); + + if (doc.Status == DocumentStatus.Processing) + throw new RAG.Domain.Exceptions.BusinessException("文档正在处理中"); + + var kb = await db.KnowledgeBases.FindAsync([doc.KnowledgeBaseId], ct) + ?? throw new NotFoundException("知识库不存在"); + + doc.Status = DocumentStatus.Processing; + await db.SaveChangesAsync(ct); + + try + { + // 读取文件内容 + var content = await File.ReadAllTextAsync(doc.FilePath, ct); + + // 分块 + var chunks = chunker.Chunk(content, kb.ChunkSize, kb.ChunkOverlap); + + // 批量向量化 + var texts = chunks.Select(c => c.Content).ToList(); + var vectors = await embeddingService.EmbedBatchAsync(texts, ct); + + // 删除旧分块 + var existingChunks = await db.DocumentChunks + .Where(c => c.DocumentId == doc.Id) + .ToListAsync(ct); + db.DocumentChunks.RemoveRange(existingChunks); + + // 保存新分块 + var chunkEntities = new List(); + for (var i = 0; i < chunks.Count; i++) + { + var chunk = new DocumentChunk + { + DocumentId = doc.Id, + Content = chunks[i].Content, + ChunkIndex = chunks[i].Index, + TokenCount = chunks[i].Content.Length / 4, // 粗略估算 + Embedding = vectors[i] + }; + chunkEntities.Add(chunk); + } + + await db.DocumentChunks.AddRangeAsync(chunkEntities, ct); + + doc.ChunkCount = chunks.Count; + doc.Status = DocumentStatus.Completed; + await db.SaveChangesAsync(ct); + + // EF Ignore 了 Embedding 属性,用 raw SQL 写入向量 + for (var i = 0; i < chunkEntities.Count; i++) + { + var vectorStr = $"[{string.Join(",", vectors[i])}]"; + var chunkId = chunkEntities[i].Id; + await db.Database.ExecuteSqlAsync( + $"UPDATE document_chunks SET embedding = {vectorStr}::vector WHERE \"Id\" = {chunkId}", ct); + } + + return new ProcessDocumentResponse(doc.Id, doc.ChunkCount, doc.Status.ToString()); + } + catch + { + doc.Status = DocumentStatus.Failed; + await db.SaveChangesAsync(ct); + throw; + } + } +} diff --git a/src/RAG.Application/Document/Commands/UploadDocumentCommand.cs b/src/RAG.Application/Document/Commands/UploadDocumentCommand.cs new file mode 100644 index 0000000..863152c --- /dev/null +++ b/src/RAG.Application/Document/Commands/UploadDocumentCommand.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Document.DTOs; +using RAG.Domain.Entities; +using RAG.Domain.Enums; +using RAG.Domain.Exceptions; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Document.Commands; + +public record UploadDocumentCommand(Guid KnowledgeBaseId, string Title, string FileName, string FilePath, + long FileSize, string ContentType) : IRequest; + +public class UploadDocumentCommandHandler(RagDbContext db) : IRequestHandler +{ + public async Task Handle(UploadDocumentCommand request, CancellationToken ct) + { + var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct) + ?? throw new NotFoundException("知识库不存在"); + + var doc = new RAG.Domain.Entities.Document + { + KnowledgeBaseId = request.KnowledgeBaseId, + Title = request.Title, + FileName = request.FileName, + FilePath = request.FilePath, + FileSize = request.FileSize, + ContentType = request.ContentType, + Status = DocumentStatus.Pending + }; + + await db.Documents.AddAsync(doc, ct); + await db.SaveChangesAsync(ct); + + return new DocumentDto(doc.Id, doc.KnowledgeBaseId, doc.Title, doc.FileName, + doc.FileSize, doc.ContentType, doc.ChunkCount, doc.Status.ToString(), doc.CreatedAt); + } +} diff --git a/src/RAG.Application/Document/DTOs/DocumentDTOs.cs b/src/RAG.Application/Document/DTOs/DocumentDTOs.cs new file mode 100644 index 0000000..3d22dfd --- /dev/null +++ b/src/RAG.Application/Document/DTOs/DocumentDTOs.cs @@ -0,0 +1,16 @@ +namespace RAG.Application.Document.DTOs; + +public record DocumentDto(Guid Id, Guid KnowledgeBaseId, string Title, string FileName, long FileSize, + string ContentType, int ChunkCount, string Status, DateTime CreatedAt); + +public record ProcessDocumentResponse(Guid DocumentId, int ChunkCount, string Status); + +public record ChunkPreviewDto( + Guid Id, + int ChunkIndex, + string Content, + int TokenCount, + bool HasEmbedding, + int? EmbeddingDimension, + List? EmbeddingPreview +); diff --git a/src/RAG.Application/Document/Queries/GetDocumentChunksQuery.cs b/src/RAG.Application/Document/Queries/GetDocumentChunksQuery.cs new file mode 100644 index 0000000..025ad40 --- /dev/null +++ b/src/RAG.Application/Document/Queries/GetDocumentChunksQuery.cs @@ -0,0 +1,75 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Document.DTOs; +using RAG.Domain.Exceptions; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Document.Queries; + +public record GetDocumentChunksQuery(Guid DocumentId) : IRequest>; + +public class GetDocumentChunksQueryHandler(RagDbContext db) + : IRequestHandler> +{ + public async Task> Handle(GetDocumentChunksQuery request, CancellationToken ct) + { + var doc = await db.Documents.FindAsync([request.DocumentId], ct) + ?? throw new NotFoundException("文档不存在"); + + var chunks = await db.DocumentChunks + .Where(c => c.DocumentId == request.DocumentId) + .OrderBy(c => c.ChunkIndex) + .Select(c => new { c.Id, c.ChunkIndex, c.Content, c.TokenCount }) + .ToListAsync(ct); + + // 查询 embedding 状态和预览(EF Ignore 了 Embedding,用 raw SQL) + var conn = db.Database.GetDbConnection(); + await conn.OpenAsync(ct); + var embeddingInfo = new Dictionary? Preview)>(); + + try + { + var ids = string.Join("','", chunks.Select(c => c.Id.ToString())); + using var cmd = conn.CreateCommand(); + cmd.CommandText = $""" + SELECT "Id", + embedding IS NOT NULL as has_embedding, + CASE WHEN embedding IS NOT NULL THEN vector_dims(embedding) ELSE NULL END as dim, + embedding::text as embedding_text + FROM document_chunks + WHERE "DocumentId" = '{request.DocumentId}' + ORDER BY "ChunkIndex" + """; + using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var id = (Guid)reader["Id"]; + var hasEmbedding = reader["has_embedding"] is bool b && b; + var dimObj = reader["dim"]; + int? dim = dimObj is int d ? d : (dimObj is string s && int.TryParse(s, out var parsed) ? parsed : (int?)null); + List? preview = null; + + if (hasEmbedding) + { + var text = reader["embedding_text"]?.ToString() ?? ""; + // 解析 [0.1, 0.2, ...] 取前10个 + var cleaned = text.Trim('[', ']'); + var parts = cleaned.Split(',', StringSplitOptions.RemoveEmptyEntries); + preview = parts.Take(10).Select(p => float.TryParse(p.Trim(), out var v) ? v : 0f).ToList(); + } + + embeddingInfo[id] = (hasEmbedding, dim, preview); + } + } + finally + { + await conn.CloseAsync(); + } + + return chunks.Select(c => + { + var info = embeddingInfo.GetValueOrDefault(c.Id); + return new ChunkPreviewDto(c.Id, c.ChunkIndex, c.Content, c.TokenCount, info.Has, info.Dim, info.Preview); + }).ToList(); + } +} diff --git a/src/RAG.Application/Document/Queries/GetDocumentsQuery.cs b/src/RAG.Application/Document/Queries/GetDocumentsQuery.cs new file mode 100644 index 0000000..0053529 --- /dev/null +++ b/src/RAG.Application/Document/Queries/GetDocumentsQuery.cs @@ -0,0 +1,21 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.Document.DTOs; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.Document.Queries; + +public record GetDocumentsQuery(Guid KnowledgeBaseId) : IRequest>; + +public class GetDocumentsQueryHandler(RagDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetDocumentsQuery request, CancellationToken ct) + { + return await db.Documents + .Where(d => d.KnowledgeBaseId == request.KnowledgeBaseId) + .OrderByDescending(d => d.CreatedAt) + .Select(d => new DocumentDto(d.Id, d.KnowledgeBaseId, d.Title, d.FileName, + d.FileSize, d.ContentType, d.ChunkCount, d.Status.ToString(), d.CreatedAt)) + .ToListAsync(ct); + } +} diff --git a/src/RAG.Application/Document/Validators/DocumentValidators.cs b/src/RAG.Application/Document/Validators/DocumentValidators.cs new file mode 100644 index 0000000..28c4f01 --- /dev/null +++ b/src/RAG.Application/Document/Validators/DocumentValidators.cs @@ -0,0 +1,22 @@ +using FluentValidation; + +namespace RAG.Application.Document.Validators; + +public class UploadDocumentCommandValidator : AbstractValidator +{ + public UploadDocumentCommandValidator() + { + RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空"); + RuleFor(x => x.Title).NotEmpty().WithMessage("文档标题不能为空").MaximumLength(500); + RuleFor(x => x.FileName).NotEmpty().MaximumLength(500); + RuleFor(x => x.FilePath).NotEmpty().MaximumLength(1000); + } +} + +public class ProcessDocumentCommandValidator : AbstractValidator +{ + public ProcessDocumentCommandValidator() + { + RuleFor(x => x.DocumentId).NotEmpty().WithMessage("文档ID不能为空"); + } +} diff --git a/src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs b/src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs new file mode 100644 index 0000000..c1ddab4 --- /dev/null +++ b/src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs @@ -0,0 +1,18 @@ +using MediatR; +using RAG.Application.Embedding.DTOs; +using RAG.Domain.Interfaces; + +namespace RAG.Application.Embedding.Commands; + +public record EmbedBatchCommand(List Texts) : IRequest; + +public class EmbedBatchCommandHandler(IEmbeddingService embeddingService) + : IRequestHandler +{ + public async Task Handle(EmbedBatchCommand request, CancellationToken ct) + { + var vectors = await embeddingService.EmbedBatchAsync(request.Texts, ct); + var dimensions = vectors.FirstOrDefault()?.Length ?? 0; + return new EmbeddingBatchResponse(vectors.Select(v => v.ToList()).ToList(), dimensions); + } +} diff --git a/src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs b/src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs new file mode 100644 index 0000000..e575207 --- /dev/null +++ b/src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs @@ -0,0 +1,17 @@ +using MediatR; +using RAG.Application.Embedding.DTOs; +using RAG.Domain.Interfaces; + +namespace RAG.Application.Embedding.Commands; + +public record EmbedTextCommand(string Text) : IRequest; + +public class EmbedTextCommandHandler(IEmbeddingService embeddingService) + : IRequestHandler +{ + public async Task Handle(EmbedTextCommand request, CancellationToken ct) + { + var vector = await embeddingService.EmbedAsync(request.Text, ct); + return new EmbeddingResponse(vector.ToList(), vector.Length); + } +} diff --git a/src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs b/src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs new file mode 100644 index 0000000..4249be4 --- /dev/null +++ b/src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs @@ -0,0 +1,5 @@ +namespace RAG.Application.Embedding.DTOs; + +public record EmbeddingResponse(List Vector, int Dimensions); + +public record EmbeddingBatchResponse(List> Vectors, int Dimensions); diff --git a/src/RAG.Application/Embedding/Validators/EmbeddingValidators.cs b/src/RAG.Application/Embedding/Validators/EmbeddingValidators.cs new file mode 100644 index 0000000..a08d4b1 --- /dev/null +++ b/src/RAG.Application/Embedding/Validators/EmbeddingValidators.cs @@ -0,0 +1,21 @@ +using FluentValidation; + +namespace RAG.Application.Embedding.Validators; + +public class EmbedTextCommandValidator : AbstractValidator +{ + public EmbedTextCommandValidator() + { + RuleFor(x => x.Text).NotEmpty().WithMessage("文本内容不能为空") + .MaximumLength(10000).WithMessage("单条文本不能超过10000个字符"); + } +} + +public class EmbedBatchCommandValidator : AbstractValidator +{ + public EmbedBatchCommandValidator() + { + RuleFor(x => x.Texts).NotEmpty().WithMessage("文本列表不能为空") + .Must(t => t.Count <= 100).WithMessage("批量文本不能超过100条"); + } +} diff --git a/src/RAG.Application/KnowledgeBase/Commands/CreateKnowledgeBaseCommand.cs b/src/RAG.Application/KnowledgeBase/Commands/CreateKnowledgeBaseCommand.cs new file mode 100644 index 0000000..ee0a6c0 --- /dev/null +++ b/src/RAG.Application/KnowledgeBase/Commands/CreateKnowledgeBaseCommand.cs @@ -0,0 +1,29 @@ +using MediatR; +using RAG.Application.KnowledgeBase.DTOs; +using RAG.Domain.Entities; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.KnowledgeBase.Commands; + +public record CreateKnowledgeBaseCommand(string Name, string? Description, string? EmbeddingModel, int? ChunkSize, int? ChunkOverlap) : IRequest; + +public class CreateKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler +{ + public async Task Handle(CreateKnowledgeBaseCommand request, CancellationToken ct) + { + var kb = new RAG.Domain.Entities.KnowledgeBase + { + Name = request.Name, + Description = request.Description, + EmbeddingModel = request.EmbeddingModel ?? "text-embedding-embeddinggemma-300m", + ChunkSize = request.ChunkSize ?? 500, + ChunkOverlap = request.ChunkOverlap ?? 50 + }; + + await db.KnowledgeBases.AddAsync(kb, ct); + await db.SaveChangesAsync(ct); + + return new KnowledgeBaseDto(kb.Id, kb.Name, kb.Description, kb.EmbeddingModel, + kb.ChunkSize, kb.ChunkOverlap, kb.Status.ToString(), kb.CreatedAt); + } +} diff --git a/src/RAG.Application/KnowledgeBase/Commands/DeleteKnowledgeBaseCommand.cs b/src/RAG.Application/KnowledgeBase/Commands/DeleteKnowledgeBaseCommand.cs new file mode 100644 index 0000000..ab803ef --- /dev/null +++ b/src/RAG.Application/KnowledgeBase/Commands/DeleteKnowledgeBaseCommand.cs @@ -0,0 +1,20 @@ +using MediatR; +using RAG.Domain.Exceptions; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.KnowledgeBase.Commands; + +public record DeleteKnowledgeBaseCommand(Guid Id) : IRequest; + +public class DeleteKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler +{ + public async Task Handle(DeleteKnowledgeBaseCommand request, CancellationToken ct) + { + var kb = await db.KnowledgeBases.FindAsync([request.Id], ct) + ?? throw new NotFoundException("知识库不存在"); + + db.KnowledgeBases.Remove(kb); + await db.SaveChangesAsync(ct); + return Unit.Value; + } +} diff --git a/src/RAG.Application/KnowledgeBase/DTOs/KnowledgeBaseDTOs.cs b/src/RAG.Application/KnowledgeBase/DTOs/KnowledgeBaseDTOs.cs new file mode 100644 index 0000000..000cc11 --- /dev/null +++ b/src/RAG.Application/KnowledgeBase/DTOs/KnowledgeBaseDTOs.cs @@ -0,0 +1,9 @@ +namespace RAG.Application.KnowledgeBase.DTOs; + +public record KnowledgeBaseDto(Guid Id, string Name, string? Description, string EmbeddingModel, + int ChunkSize, int ChunkOverlap, string Status, DateTime CreatedAt); + +public record CreateKnowledgeBaseRequest(string Name, string? Description, string? EmbeddingModel, + int? ChunkSize, int? ChunkOverlap); + +public record UpdateKnowledgeBaseRequest(string? Name, string? Description, int? ChunkSize, int? ChunkOverlap); diff --git a/src/RAG.Application/KnowledgeBase/Queries/GetKnowledgeBasesQuery.cs b/src/RAG.Application/KnowledgeBase/Queries/GetKnowledgeBasesQuery.cs new file mode 100644 index 0000000..9261441 --- /dev/null +++ b/src/RAG.Application/KnowledgeBase/Queries/GetKnowledgeBasesQuery.cs @@ -0,0 +1,20 @@ +using MediatR; +using RAG.Application.KnowledgeBase.DTOs; +using RAG.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace RAG.Application.KnowledgeBase.Queries; + +public record GetKnowledgeBasesQuery : IRequest>; + +public class GetKnowledgeBasesQueryHandler(RagDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetKnowledgeBasesQuery request, CancellationToken ct) + { + return await db.KnowledgeBases + .OrderByDescending(k => k.CreatedAt) + .Select(k => new KnowledgeBaseDto(k.Id, k.Name, k.Description, k.EmbeddingModel, + k.ChunkSize, k.ChunkOverlap, k.Status.ToString(), k.CreatedAt)) + .ToListAsync(ct); + } +} diff --git a/src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs b/src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs new file mode 100644 index 0000000..4cbc9f2 --- /dev/null +++ b/src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace RAG.Application.KnowledgeBase.Validators; + +public class CreateKnowledgeBaseCommandValidator : AbstractValidator +{ + public CreateKnowledgeBaseCommandValidator() + { + RuleFor(x => x.Name).NotEmpty().WithMessage("知识库名称不能为空") + .MaximumLength(200).WithMessage("名称不能超过200个字符"); + RuleFor(x => x.Description).MaximumLength(1000).When(x => x.Description != null); + } +} diff --git a/src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs b/src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs new file mode 100644 index 0000000..db84c49 --- /dev/null +++ b/src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs @@ -0,0 +1,165 @@ +using System.Text; +using MediatR; +using Microsoft.EntityFrameworkCore; +using RAG.Application.RagQA.DTOs; +using RAG.Domain.Entities; +using RAG.Domain.Enums; +using RAG.Domain.Exceptions; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.Persistence; + +namespace RAG.Application.RagQA.Commands; + +public record RAGQueryCommand(Guid KnowledgeBaseId, string Question) : IRequest; + +public class RAGQueryCommandHandler(RagDbContext db, IEmbeddingService embeddingService, IAIChatAgent chatAgent) + : IRequestHandler +{ + private const int VectorTopK = 20; + private const int FinalTopK = 5; + private const double MinSimilarity = 0.3; + + public async Task Handle(RAGQueryCommand request, CancellationToken ct) + { + var kb = await db.KnowledgeBases.FindAsync([request.KnowledgeBaseId], ct) + ?? throw new NotFoundException("知识库不存在"); + + // 向量化问题 + var queryVector = await embeddingService.EmbedAsync(request.Question, ct); + var vectorStr = $"[{string.Join(",", queryVector)}]"; + + // 第一阶段:向量粗召回 Top-20 + var vectorSql = $""" + SELECT dc."Content", d."Title" as document_title, dc."DocumentId", + 1 - (dc.embedding <=> '{vectorStr}'::vector) AS similarity + FROM document_chunks dc + JOIN documents d ON dc."DocumentId" = d."Id" + WHERE d."KnowledgeBaseId" = '{request.KnowledgeBaseId}' + AND dc.embedding IS NOT NULL + ORDER BY dc.embedding <=> '{vectorStr}'::vector + LIMIT {VectorTopK} + """; + + var candidates = new List<(Guid DocId, string DocTitle, string Content, double VectorScore)>(); + var conn = db.Database.GetDbConnection(); + await conn.OpenAsync(ct); + try + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = vectorSql; + using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var similarity = reader["similarity"] is double d ? d : 0.0; + if (similarity < MinSimilarity) continue; + candidates.Add(( + reader["DocumentId"] is Guid g ? g : Guid.Empty, + reader["document_title"]?.ToString() ?? "", + reader["Content"]?.ToString() ?? "", + similarity + )); + } + } + finally + { + await conn.CloseAsync(); + } + + if (candidates.Count == 0) + return new RAGQueryResponse("未在知识库中找到相关内容,无法回答该问题。", []); + + // 第二阶段:关键词匹配重排序 + var keywords = ExtractKeywords(request.Question); + var ranked = candidates.Select(c => + { + var keywordScore = KeywordMatchScore(c.Content, keywords); + // RRF 融合:向量分权重 0.7 + 关键词分权重 0.3 + var finalScore = c.VectorScore * 0.7 + keywordScore * 0.3; + return (c.DocId, c.DocTitle, c.Content, FinalScore: finalScore); + }) + .OrderByDescending(x => x.FinalScore) + .Take(FinalTopK) + .ToList(); + + var sources = ranked.Select(r => new SourceChunk( + r.DocId, + r.DocTitle, + r.Content.Length > 200 ? r.Content[..200] + "..." : r.Content, + Math.Round(r.FinalScore, 4) + )).ToList(); + + // 构建 RAG prompt + var contextBuilder = new StringBuilder(); + contextBuilder.AppendLine("以下是检索到的相关上下文:"); + for (var i = 0; i < ranked.Count; i++) + contextBuilder.AppendLine($"\n--- 上下文 {i + 1} ---\n{ranked[i].Content}"); + + var prompt = $""" + {contextBuilder} + + 基于以上上下文,请回答以下问题。如果上下文中没有相关信息,请如实说明。 + + 问题:{request.Question} + """; + + var answer = await chatAgent.RunAsync(prompt, ct); + return new RAGQueryResponse(answer, sources); + } + + /// 提取中文/英文关键词(简单分词) + private static List ExtractKeywords(string question) + { + // 去除常见疑问词 + var stopWords = new HashSet + { + "的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都", + "这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪", + "哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会", + "what", "how", "why", "is", "the", "a", "an", "of", "to", "in" + }; + + var words = new List(); + + // 提取中文词组(2-4字)和英文单词 + var englishWords = System.Text.RegularExpressions.Regex.Matches(question, @"[a-zA-Z]+") + .Select(m => m.Value.ToLowerInvariant()) + .Where(w => w.Length >= 2 && !stopWords.Contains(w)); + + words.AddRange(englishWords); + + // 提取中文连续字符段,按2-4字滑窗 + var chineseSegments = System.Text.RegularExpressions.Regex.Matches(question, @"[一-鿿]+"); + foreach (System.Text.RegularExpressions.Match seg in chineseSegments) + { + var text = seg.Value; + if (text.Length <= 4) + { + if (!stopWords.Contains(text)) + words.Add(text); + } + else + { + // 滑窗提取 2-4 字子串 + for (var len = 2; len <= Math.Min(4, text.Length); len++) + { + for (var i = 0; i <= text.Length - len; i++) + { + var sub = text[i..(i + len)]; + if (!stopWords.Contains(sub)) + words.Add(sub); + } + } + } + } + + return words.Distinct().ToList(); + } + + /// 计算关键词匹配得分(0-1) + private static double KeywordMatchScore(string content, List keywords) + { + if (keywords.Count == 0) return 0; + var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase)); + return (double)matched / keywords.Count; + } +} diff --git a/src/RAG.Application/RagQA/DTOs/RAGDTOs.cs b/src/RAG.Application/RagQA/DTOs/RAGDTOs.cs new file mode 100644 index 0000000..a91ff59 --- /dev/null +++ b/src/RAG.Application/RagQA/DTOs/RAGDTOs.cs @@ -0,0 +1,7 @@ +namespace RAG.Application.RagQA.DTOs; + +public record RAGQueryRequest(Guid KnowledgeBaseId, string Question); + +public record RAGQueryResponse(string Answer, List Sources); + +public record SourceChunk(Guid DocumentId, string DocumentTitle, string Content, double Similarity); diff --git a/src/RAG.Application/RagQA/Validators/RAGValidators.cs b/src/RAG.Application/RagQA/Validators/RAGValidators.cs new file mode 100644 index 0000000..fcf1e75 --- /dev/null +++ b/src/RAG.Application/RagQA/Validators/RAGValidators.cs @@ -0,0 +1,13 @@ +using FluentValidation; + +namespace RAG.Application.RagQA.Validators; + +public class RAGQueryCommandValidator : AbstractValidator +{ + public RAGQueryCommandValidator() + { + RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空"); + RuleFor(x => x.Question).NotEmpty().WithMessage("问题不能为空") + .MaximumLength(5000).WithMessage("问题不能超过5000个字符"); + } +} diff --git a/src/RAG.Domain/Common/BaseEntity.cs b/src/RAG.Domain/Common/BaseEntity.cs index 01239d6..022f5f1 100644 --- a/src/RAG.Domain/Common/BaseEntity.cs +++ b/src/RAG.Domain/Common/BaseEntity.cs @@ -12,4 +12,7 @@ public abstract class BaseEntity where TId : struct /// /// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。 /// -public abstract class BaseEntity : BaseEntity { } +public abstract class BaseEntity : BaseEntity +{ + public BaseEntity() => Id = Guid.NewGuid(); +} diff --git a/src/RAG.Domain/Entities/ChatMessage.cs b/src/RAG.Domain/Entities/ChatMessage.cs new file mode 100644 index 0000000..4a13a22 --- /dev/null +++ b/src/RAG.Domain/Entities/ChatMessage.cs @@ -0,0 +1,21 @@ +using RAG.Domain.Common; +using RAG.Domain.Enums; + +namespace RAG.Domain.Entities; + +public class ChatMessage : BaseEntity, IAuditable +{ + public Guid ConversationId { get; set; } + public ChatRole Role { get; set; } + public string Content { get; set; } = default!; + public int? TokenUsage { get; set; } + + // IAuditable + public string CreatedBy { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public string UpdatedBy { get; set; } = default!; + public DateTime UpdatedAt { get; set; } + + // Navigation + public Conversation Conversation { get; set; } = default!; +} diff --git a/src/RAG.Domain/Entities/Conversation.cs b/src/RAG.Domain/Entities/Conversation.cs new file mode 100644 index 0000000..8528235 --- /dev/null +++ b/src/RAG.Domain/Entities/Conversation.cs @@ -0,0 +1,27 @@ +using RAG.Domain.Common; + +namespace RAG.Domain.Entities; + +public class Conversation : BaseEntity, IFullAudit +{ + public string Title { get; set; } = default!; + public Guid UserId { get; set; } + public Guid? KnowledgeBaseId { get; set; } + + // IAuditable + public string CreatedBy { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public string UpdatedBy { get; set; } = default!; + public DateTime UpdatedAt { get; set; } + + // ISoftDelete + public bool IsDeleted { get; set; } + + // IHasOperatorIP + public string OperatorIP { get; set; } = default!; + + // Navigation + public User User { get; set; } = default!; + public KnowledgeBase? KnowledgeBase { get; set; } + public ICollection Messages { get; set; } = []; +} diff --git a/src/RAG.Domain/Entities/Document.cs b/src/RAG.Domain/Entities/Document.cs new file mode 100644 index 0000000..d345ab0 --- /dev/null +++ b/src/RAG.Domain/Entities/Document.cs @@ -0,0 +1,32 @@ +using RAG.Domain.Common; +using RAG.Domain.Enums; + +namespace RAG.Domain.Entities; + +public class Document : BaseEntity, IFullAudit +{ + public Guid KnowledgeBaseId { get; set; } + public string Title { get; set; } = default!; + public string FileName { get; set; } = default!; + public string FilePath { get; set; } = default!; + public long FileSize { get; set; } + public string ContentType { get; set; } = default!; + public int ChunkCount { get; set; } + public DocumentStatus Status { get; set; } = DocumentStatus.Pending; + + // IAuditable + public string CreatedBy { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public string UpdatedBy { get; set; } = default!; + public DateTime UpdatedAt { get; set; } + + // ISoftDelete + public bool IsDeleted { get; set; } + + // IHasOperatorIP + public string OperatorIP { get; set; } = default!; + + // Navigation + public KnowledgeBase KnowledgeBase { get; set; } = default!; + public ICollection Chunks { get; set; } = []; +} diff --git a/src/RAG.Domain/Entities/DocumentChunk.cs b/src/RAG.Domain/Entities/DocumentChunk.cs new file mode 100644 index 0000000..4dcb4d2 --- /dev/null +++ b/src/RAG.Domain/Entities/DocumentChunk.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; +using RAG.Domain.Common; + +namespace RAG.Domain.Entities; + +public class DocumentChunk : BaseEntity, IAuditable +{ + public Guid DocumentId { get; set; } + public string Content { get; set; } = default!; + public int ChunkIndex { get; set; } + public int TokenCount { get; set; } + public float[]? Embedding { get; set; } + + // IAuditable + public string CreatedBy { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public string UpdatedBy { get; set; } = default!; + public DateTime UpdatedAt { get; set; } + + // Navigation + public Document Document { get; set; } = default!; +} diff --git a/src/RAG.Domain/Entities/KnowledgeBase.cs b/src/RAG.Domain/Entities/KnowledgeBase.cs new file mode 100644 index 0000000..26c9cff --- /dev/null +++ b/src/RAG.Domain/Entities/KnowledgeBase.cs @@ -0,0 +1,29 @@ +using RAG.Domain.Common; +using RAG.Domain.Enums; + +namespace RAG.Domain.Entities; + +public class KnowledgeBase : BaseEntity, IFullAudit +{ + public string Name { get; set; } = default!; + public string? Description { get; set; } + public string EmbeddingModel { get; set; } = "text-embedding-embeddinggemma-300m"; + public int ChunkSize { get; set; } = 500; + public int ChunkOverlap { get; set; } = 50; + public KnowledgeBaseStatus Status { get; set; } = KnowledgeBaseStatus.Active; + + // IAuditable + public string CreatedBy { get; set; } = default!; + public DateTime CreatedAt { get; set; } + public string UpdatedBy { get; set; } = default!; + public DateTime UpdatedAt { get; set; } + + // ISoftDelete + public bool IsDeleted { get; set; } + + // IHasOperatorIP + public string OperatorIP { get; set; } = default!; + + // Navigation + public ICollection Documents { get; set; } = []; +} diff --git a/src/RAG.Domain/Enums/ChatRole.cs b/src/RAG.Domain/Enums/ChatRole.cs new file mode 100644 index 0000000..919dd67 --- /dev/null +++ b/src/RAG.Domain/Enums/ChatRole.cs @@ -0,0 +1,8 @@ +namespace RAG.Domain.Enums; + +public enum ChatRole +{ + System = 0, + User = 1, + Assistant = 2 +} diff --git a/src/RAG.Domain/Enums/DocumentStatus.cs b/src/RAG.Domain/Enums/DocumentStatus.cs new file mode 100644 index 0000000..aea1305 --- /dev/null +++ b/src/RAG.Domain/Enums/DocumentStatus.cs @@ -0,0 +1,9 @@ +namespace RAG.Domain.Enums; + +public enum DocumentStatus +{ + Pending = 0, + Processing = 1, + Completed = 2, + Failed = 3 +} diff --git a/src/RAG.Domain/Enums/KnowledgeBaseStatus.cs b/src/RAG.Domain/Enums/KnowledgeBaseStatus.cs new file mode 100644 index 0000000..673a2ba --- /dev/null +++ b/src/RAG.Domain/Enums/KnowledgeBaseStatus.cs @@ -0,0 +1,7 @@ +namespace RAG.Domain.Enums; + +public enum KnowledgeBaseStatus +{ + Active = 0, + Inactive = 1 +} diff --git a/src/RAG.Domain/Interfaces/IAIChatAgent.cs b/src/RAG.Domain/Interfaces/IAIChatAgent.cs new file mode 100644 index 0000000..170c452 --- /dev/null +++ b/src/RAG.Domain/Interfaces/IAIChatAgent.cs @@ -0,0 +1,7 @@ +namespace RAG.Domain.Interfaces; + +public interface IAIChatAgent +{ + Task RunAsync(string prompt, CancellationToken ct = default); + IAsyncEnumerable RunStreamingAsync(string prompt, CancellationToken ct = default); +} diff --git a/src/RAG.Domain/Interfaces/IChatMessageCache.cs b/src/RAG.Domain/Interfaces/IChatMessageCache.cs new file mode 100644 index 0000000..ac0d58b --- /dev/null +++ b/src/RAG.Domain/Interfaces/IChatMessageCache.cs @@ -0,0 +1,18 @@ +namespace RAG.Domain.Interfaces; + +public interface IChatMessageCache +{ + /// 获取会话的消息列表(缓存 miss 时返回 null) + Task?> GetMessagesAsync(Guid conversationId, CancellationToken ct = default); + + /// 设置会话消息缓存 + Task SetMessagesAsync(Guid conversationId, List messages, CancellationToken ct = default); + + /// 追加一条消息到缓存 + Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct = default); + + /// 删除会话缓存 + Task RemoveAsync(Guid conversationId, CancellationToken ct = default); +} + +public record CachedChatMessage(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt); diff --git a/src/RAG.Domain/Interfaces/IEmbeddingService.cs b/src/RAG.Domain/Interfaces/IEmbeddingService.cs new file mode 100644 index 0000000..b0d1d85 --- /dev/null +++ b/src/RAG.Domain/Interfaces/IEmbeddingService.cs @@ -0,0 +1,7 @@ +namespace RAG.Domain.Interfaces; + +public interface IEmbeddingService +{ + Task EmbedAsync(string text, CancellationToken ct = default); + Task> EmbedBatchAsync(List texts, CancellationToken ct = default); +} diff --git a/src/RAG.Domain/Interfaces/ITextChunker.cs b/src/RAG.Domain/Interfaces/ITextChunker.cs new file mode 100644 index 0000000..985ee49 --- /dev/null +++ b/src/RAG.Domain/Interfaces/ITextChunker.cs @@ -0,0 +1,8 @@ +namespace RAG.Domain.Interfaces; + +public record TextChunk(string Content, int Index, int StartPosition, int EndPosition); + +public interface ITextChunker +{ + List Chunk(string content, int chunkSize = 500, int overlap = 50); +} diff --git a/src/RAG.Domain/RAG.Domain.csproj b/src/RAG.Domain/RAG.Domain.csproj index fce1380..e6656d8 100644 --- a/src/RAG.Domain/RAG.Domain.csproj +++ b/src/RAG.Domain/RAG.Domain.csproj @@ -8,6 +8,7 @@ + diff --git a/src/RAG.Infrastructure/AI/AiOptions.cs b/src/RAG.Infrastructure/AI/AiOptions.cs new file mode 100644 index 0000000..b41b172 --- /dev/null +++ b/src/RAG.Infrastructure/AI/AiOptions.cs @@ -0,0 +1,14 @@ +namespace RAG.Infrastructure.AI; + +public class AiOptions +{ + public const string SectionName = "Ai"; + + public string BaseUrl { get; set; } = "http://localhost:1234/v1"; + public string ApiKey { get; set; } = "lm-studio"; + public string ChatModel { get; set; } = ""; + public string EmbeddingModel { get; set; } = ""; + public string DefaultInstructions { get; set; } = ""; + public int MaxTokens { get; set; } = 2000; + public double Temperature { get; set; } = 0.7; +} diff --git a/src/RAG.Infrastructure/AI/ChatAgentService.cs b/src/RAG.Infrastructure/AI/ChatAgentService.cs new file mode 100644 index 0000000..c1239a3 --- /dev/null +++ b/src/RAG.Infrastructure/AI/ChatAgentService.cs @@ -0,0 +1,43 @@ +using System.Runtime.CompilerServices; +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Chat; +using RAG.Domain.Interfaces; + +namespace RAG.Infrastructure.AI; + +public class ChatAgentService : IAIChatAgent +{ + private readonly AIAgent _agent; + + public ChatAgentService(IOptions options) + { + var opt = options.Value; + var client = new OpenAIClient( + new ApiKeyCredential(opt.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(opt.BaseUrl) }); + + var chatClient = client.GetChatClient(opt.ChatModel); + _agent = chatClient.AsAIAgent( + instructions: opt.DefaultInstructions, + name: "RagAssistant"); + } + + public async Task RunAsync(string prompt, CancellationToken ct) + { + var response = await _agent.RunAsync(prompt, null, null, ct); + return response.Text; + } + + public async IAsyncEnumerable RunStreamingAsync( + string prompt, [EnumeratorCancellation] CancellationToken ct) + { + await foreach (var update in _agent.RunStreamingAsync(prompt, null, null, ct)) + { + if (!string.IsNullOrEmpty(update.Text)) + yield return update.Text; + } + } +} diff --git a/src/RAG.Infrastructure/AI/EmbeddingService.cs b/src/RAG.Infrastructure/AI/EmbeddingService.cs new file mode 100644 index 0000000..edbf982 --- /dev/null +++ b/src/RAG.Infrastructure/AI/EmbeddingService.cs @@ -0,0 +1,34 @@ +using System.ClientModel; +using Microsoft.Extensions.Options; +using OpenAI; +using OpenAI.Embeddings; +using RAG.Domain.Interfaces; + +namespace RAG.Infrastructure.AI; + +public class EmbeddingService : IEmbeddingService +{ + private readonly EmbeddingClient _client; + + public EmbeddingService(IOptions options) + { + var opt = options.Value; + var openaiClient = new OpenAIClient( + new ApiKeyCredential(opt.ApiKey), + new OpenAIClientOptions { Endpoint = new Uri(opt.BaseUrl) }); + + _client = openaiClient.GetEmbeddingClient(opt.EmbeddingModel); + } + + public async Task EmbedAsync(string text, CancellationToken ct) + { + var response = await _client.GenerateEmbeddingAsync(text, cancellationToken: ct); + return response.Value.ToFloats().ToArray(); + } + + public async Task> EmbedBatchAsync(List texts, CancellationToken ct) + { + var response = await _client.GenerateEmbeddingsAsync(texts, cancellationToken: ct); + return response.Value.Select(e => e.ToFloats().ToArray()).ToList(); + } +} diff --git a/src/RAG.Infrastructure/AI/TextChunker.cs b/src/RAG.Infrastructure/AI/TextChunker.cs new file mode 100644 index 0000000..f262f1f --- /dev/null +++ b/src/RAG.Infrastructure/AI/TextChunker.cs @@ -0,0 +1,127 @@ +using RAG.Domain.Interfaces; + +namespace RAG.Infrastructure.AI; + +public class TextChunker : ITextChunker +{ + public List Chunk(string content, int chunkSize = 500, int overlap = 50) + { + var chunks = new List(); + if (string.IsNullOrEmpty(content)) + return chunks; + + // 先按段落分割 + var paragraphs = content.Split(["\r\n\r\n", "\n\n"], StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(); + + // 如果段落很少且内容很长,按单行再拆 + if (paragraphs.Count <= 1 && content.Length > chunkSize) + { + paragraphs = content.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => p.Length > 0) + .ToList(); + } + + var index = 0; + var currentChunk = new List(); + var currentLength = 0; + var chunkStartPosition = 0; + + foreach (var para in paragraphs) + { + // 单个段落超过 chunkSize,需要再拆分 + if (para.Length > chunkSize) + { + // 先把当前积攒的内容保存 + if (currentChunk.Count > 0) + { + chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition)); + currentChunk = []; + currentLength = 0; + } + + // 按句子拆分超长段落 + var sentences = SplitSentences(para); + var sentenceChunk = new List(); + var sentenceLength = 0; + + foreach (var sentence in sentences) + { + if (sentenceLength + sentence.Length > chunkSize && sentenceChunk.Count > 0) + { + chunks.Add(BuildChunk(sentenceChunk, index++, ref chunkStartPosition)); + // 保留 overlap + var overlapText = string.Join("", sentenceChunk.TakeLast(2)); + sentenceChunk = overlapText.Length < chunkSize / 2 ? [overlapText] : []; + sentenceLength = sentenceChunk.Sum(s => s.Length); + } + + sentenceChunk.Add(sentence); + sentenceLength += sentence.Length; + } + + if (sentenceChunk.Count > 0) + { + chunks.Add(BuildChunk(sentenceChunk, index++, ref chunkStartPosition)); + } + } + else if (currentLength + para.Length > chunkSize && currentChunk.Count > 0) + { + // 当前段落加入会超限,先保存当前 chunk + chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition)); + + // 保留 overlap:取最后一个段落作为下一个 chunk 的开头 + currentChunk = [..currentChunk.TakeLast(1)]; + currentLength = currentChunk.Sum(s => s.Length); + + currentChunk.Add(para); + currentLength += para.Length; + } + else + { + currentChunk.Add(para); + currentLength += para.Length; + } + } + + if (currentChunk.Count > 0) + { + chunks.Add(BuildChunk(currentChunk, index++, ref chunkStartPosition)); + } + + return chunks; + } + + private static TextChunk BuildChunk(List parts, int index, ref int startPosition) + { + var text = string.Join("\n\n", parts); + var chunk = new TextChunk(text, index, startPosition, startPosition + text.Length); + startPosition += text.Length; + return chunk; + } + + private static List SplitSentences(string text) + { + var sentences = new List(); + var start = 0; + + for (var i = 0; i < text.Length; i++) + { + if (text[i] is '。' or '!' or '?' or '.' or '!' or '?' or ';' or ';') + { + var end = i + 1; + if (end < text.Length) end++; + sentences.Add(text[start..Math.Min(end, text.Length)]); + start = end; + } + } + + if (start < text.Length) + sentences.Add(text[start..]); + + return sentences.Where(s => s.Trim().Length > 0).ToList(); + } +} diff --git a/src/RAG.Infrastructure/Cache/ChatMessageCache.cs b/src/RAG.Infrastructure/Cache/ChatMessageCache.cs new file mode 100644 index 0000000..fc3616c --- /dev/null +++ b/src/RAG.Infrastructure/Cache/ChatMessageCache.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using RAG.Domain.Interfaces; +using StackExchange.Redis; + +namespace RAG.Infrastructure.Cache; + +public class ChatMessageCache(IConnectionMultiplexer redis) : IChatMessageCache +{ + private readonly IDatabase _db = redis.GetDatabase(); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private static string Key(Guid conversationId) => $"chat:messages:{conversationId}"; + + public async Task?> GetMessagesAsync(Guid conversationId, CancellationToken ct) + { + var value = await _db.StringGetAsync(Key(conversationId)); + if (value.IsNullOrEmpty) return null; + return JsonSerializer.Deserialize>((string)value!, JsonOptions); + } + + public async Task SetMessagesAsync(Guid conversationId, List messages, CancellationToken ct) + { + var json = JsonSerializer.Serialize(messages, JsonOptions); + await _db.StringSetAsync(Key(conversationId), json, TimeSpan.FromHours(1)); + } + + public async Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct) + { + var messages = await GetMessagesAsync(conversationId, ct) ?? []; + messages.Add(message); + await SetMessagesAsync(conversationId, messages, ct); + } + + public async Task RemoveAsync(Guid conversationId, CancellationToken ct) + { + await _db.KeyDeleteAsync(Key(conversationId)); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs b/src/RAG.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs new file mode 100644 index 0000000..6dca63a --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Configurations/ChatMessageConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RAG.Domain.Entities; + +namespace RAG.Infrastructure.Persistence.Configurations; + +public class ChatMessageConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("chat_messages"); + builder.HasKey(m => m.Id); + builder.Property(m => m.Id).ValueGeneratedNever(); + + builder.Property(m => m.Content).IsRequired(); + builder.Property(m => m.Role).IsRequired(); + + builder.Property(m => m.CreatedBy).HasMaxLength(100); + builder.Property(m => m.UpdatedBy).HasMaxLength(100); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Configurations/ConversationConfiguration.cs b/src/RAG.Infrastructure/Persistence/Configurations/ConversationConfiguration.cs new file mode 100644 index 0000000..d81bdd0 --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Configurations/ConversationConfiguration.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RAG.Domain.Entities; + +namespace RAG.Infrastructure.Persistence.Configurations; + +public class ConversationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("conversations"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).ValueGeneratedNever(); + + builder.Property(c => c.Title).HasMaxLength(200).IsRequired(); + builder.Property(c => c.KnowledgeBaseId).IsRequired(false); + + builder.Property(c => c.CreatedBy).HasMaxLength(100); + builder.Property(c => c.UpdatedBy).HasMaxLength(100); + builder.Property(c => c.IsDeleted).HasDefaultValue(false); + builder.Property(c => c.OperatorIP).HasMaxLength(50); + + builder.HasOne(c => c.User) + .WithMany() + .HasForeignKey(c => c.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasMany(c => c.Messages) + .WithOne(m => m.Conversation) + .HasForeignKey(m => m.ConversationId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Configurations/DocumentChunkConfiguration.cs b/src/RAG.Infrastructure/Persistence/Configurations/DocumentChunkConfiguration.cs new file mode 100644 index 0000000..ed5da96 --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Configurations/DocumentChunkConfiguration.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RAG.Domain.Entities; + +namespace RAG.Infrastructure.Persistence.Configurations; + +public class DocumentChunkConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("document_chunks"); + builder.HasKey(c => c.Id); + builder.Property(c => c.Id).ValueGeneratedNever(); + + builder.Property(c => c.Content).IsRequired(); + builder.Ignore(c => c.Embedding); + + builder.Property(c => c.CreatedBy).HasMaxLength(100); + builder.Property(c => c.UpdatedBy).HasMaxLength(100); + + builder.HasIndex(c => c.DocumentId); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Configurations/DocumentConfiguration.cs b/src/RAG.Infrastructure/Persistence/Configurations/DocumentConfiguration.cs new file mode 100644 index 0000000..d23fb35 --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Configurations/DocumentConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RAG.Domain.Entities; + +namespace RAG.Infrastructure.Persistence.Configurations; + +public class DocumentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("documents"); + builder.HasKey(d => d.Id); + builder.Property(d => d.Id).ValueGeneratedNever(); + + builder.Property(d => d.Title).HasMaxLength(500).IsRequired(); + builder.Property(d => d.FileName).HasMaxLength(500).IsRequired(); + builder.Property(d => d.FilePath).HasMaxLength(1000).IsRequired(); + builder.Property(d => d.ContentType).HasMaxLength(100).IsRequired(); + + builder.Property(d => d.CreatedBy).HasMaxLength(100); + builder.Property(d => d.UpdatedBy).HasMaxLength(100); + builder.Property(d => d.IsDeleted).HasDefaultValue(false); + builder.Property(d => d.OperatorIP).HasMaxLength(50); + + builder.HasMany(d => d.Chunks) + .WithOne(c => c.Document) + .HasForeignKey(c => c.DocumentId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Configurations/KnowledgeBaseConfiguration.cs b/src/RAG.Infrastructure/Persistence/Configurations/KnowledgeBaseConfiguration.cs new file mode 100644 index 0000000..599b732 --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Configurations/KnowledgeBaseConfiguration.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using RAG.Domain.Entities; + +namespace RAG.Infrastructure.Persistence.Configurations; + +public class KnowledgeBaseConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("knowledge_bases"); + builder.HasKey(k => k.Id); + builder.Property(k => k.Id).ValueGeneratedNever(); + + builder.Property(k => k.Name).HasMaxLength(200).IsRequired(); + builder.Property(k => k.Description).HasMaxLength(1000); + builder.Property(k => k.EmbeddingModel).HasMaxLength(100).IsRequired(); + + builder.Property(k => k.CreatedBy).HasMaxLength(100); + builder.Property(k => k.UpdatedBy).HasMaxLength(100); + builder.Property(k => k.IsDeleted).HasDefaultValue(false); + builder.Property(k => k.OperatorIP).HasMaxLength(50); + + builder.HasMany(k => k.Documents) + .WithOne(d => d.KnowledgeBase) + .HasForeignKey(d => d.KnowledgeBaseId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.Designer.cs b/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.Designer.cs new file mode 100644 index 0000000..ab30d84 --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.Designer.cs @@ -0,0 +1,811 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using RAG.Infrastructure.Persistence; + +#nullable disable + +namespace RAG.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(RagDbContext))] + [Migration("20260519082511_AddAIEntities")] + partial class AddAIEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TokenUsage") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KnowledgeBaseId") + .HasColumnType("uuid"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KnowledgeBaseId"); + + b.HasIndex("UserId"); + + b.ToTable("conversations", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkCount") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KnowledgeBaseId") + .HasColumnType("uuid"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("KnowledgeBaseId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkIndex") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("TokenCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("document_chunks", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkOverlap") + .HasColumnType("integer"); + + b.Property("ChunkSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EmbeddingModel") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("knowledge_bases", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveIcon") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AffixTab") + .HasColumnType("boolean"); + + b.Property("Authority") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Component") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("HideInMenu") + .HasColumnType("boolean"); + + b.Property("Icon") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KeepAlive") + .HasColumnType("boolean"); + + b.Property("Link") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("MenuVisibleWithForbidden") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NoBasicLayout") + .HasColumnType("boolean"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Redirect") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("menus", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Permission", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Group") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("permissions", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Role", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.RolePermission", b => + { + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("PermissionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("RoleId", "PermissionId"); + + b.HasIndex("PermissionId"); + + b.ToTable("role_permissions", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b => + { + b.HasOne("RAG.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase") + .WithMany() + .HasForeignKey("KnowledgeBaseId"); + + b.HasOne("RAG.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KnowledgeBase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase") + .WithMany("Documents") + .HasForeignKey("KnowledgeBaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KnowledgeBase"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b => + { + b.HasOne("RAG.Domain.Entities.Document", "Document") + .WithMany("Chunks") + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => + { + b.HasOne("RAG.Domain.Entities.Menu", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.RefreshToken", b => + { + b.HasOne("RAG.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.RolePermission", b => + { + b.HasOne("RAG.Domain.Entities.Permission", "Permission") + .WithMany("RolePermissions") + .HasForeignKey("PermissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RAG.Domain.Entities.Role", "Role") + .WithMany("RolePermissions") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Permission"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.UserRole", b => + { + b.HasOne("RAG.Domain.Entities.Role", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("RAG.Domain.Entities.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.Navigation("Chunks"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b => + { + b.Navigation("Documents"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Permission", b => + { + b.Navigation("RolePermissions"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Role", b => + { + b.Navigation("RolePermissions"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.User", b => + { + b.Navigation("UserRoles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.cs b/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.cs new file mode 100644 index 0000000..36cd67f --- /dev/null +++ b/src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.cs @@ -0,0 +1,204 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace RAG.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddAIEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:PostgresExtension:vector", ",,"); + + migrationBuilder.CreateTable( + name: "knowledge_bases", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + EmbeddingModel = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ChunkSize = table.Column(type: "integer", nullable: false), + ChunkOverlap = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + OperatorIP = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_knowledge_bases", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "conversations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + KnowledgeBaseId = table.Column(type: "uuid", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + OperatorIP = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_conversations", x => x.Id); + table.ForeignKey( + name: "FK_conversations_knowledge_bases_KnowledgeBaseId", + column: x => x.KnowledgeBaseId, + principalTable: "knowledge_bases", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_conversations_users_UserId", + column: x => x.UserId, + principalTable: "users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "documents", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + KnowledgeBaseId = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + FileName = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + FilePath = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + FileSize = table.Column(type: "bigint", nullable: false), + ContentType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ChunkCount = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "integer", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsDeleted = table.Column(type: "boolean", nullable: false, defaultValue: false), + OperatorIP = table.Column(type: "character varying(50)", maxLength: 50, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_documents", x => x.Id); + table.ForeignKey( + name: "FK_documents_knowledge_bases_KnowledgeBaseId", + column: x => x.KnowledgeBaseId, + principalTable: "knowledge_bases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "chat_messages", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ConversationId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "integer", nullable: false), + Content = table.Column(type: "text", nullable: false), + TokenUsage = table.Column(type: "integer", nullable: true), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_chat_messages", x => x.Id); + table.ForeignKey( + name: "FK_chat_messages_conversations_ConversationId", + column: x => x.ConversationId, + principalTable: "conversations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "document_chunks", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + DocumentId = table.Column(type: "uuid", nullable: false), + Content = table.Column(type: "text", nullable: false), + ChunkIndex = table.Column(type: "integer", nullable: false), + TokenCount = table.Column(type: "integer", nullable: false), + CreatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedBy = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_document_chunks", x => x.Id); + table.ForeignKey( + name: "FK_document_chunks_documents_DocumentId", + column: x => x.DocumentId, + principalTable: "documents", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_chat_messages_ConversationId", + table: "chat_messages", + column: "ConversationId"); + + migrationBuilder.CreateIndex( + name: "IX_conversations_KnowledgeBaseId", + table: "conversations", + column: "KnowledgeBaseId"); + + migrationBuilder.CreateIndex( + name: "IX_conversations_UserId", + table: "conversations", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_document_chunks_DocumentId", + table: "document_chunks", + column: "DocumentId"); + + migrationBuilder.CreateIndex( + name: "IX_documents_KnowledgeBaseId", + table: "documents", + column: "KnowledgeBaseId"); + + migrationBuilder.Sql("ALTER TABLE document_chunks ADD COLUMN embedding vector(768)"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("ALTER TABLE document_chunks DROP COLUMN IF EXISTS embedding"); + + migrationBuilder.DropTable( + name: "chat_messages"); + + migrationBuilder.DropTable( + name: "document_chunks"); + + migrationBuilder.DropTable( + name: "conversations"); + + migrationBuilder.DropTable( + name: "documents"); + + migrationBuilder.DropTable( + name: "knowledge_bases"); + + migrationBuilder.AlterDatabase() + .OldAnnotation("Npgsql:PostgresExtension:vector", ",,"); + } + } +} diff --git a/src/RAG.Infrastructure/Persistence/Migrations/RagDbContextModelSnapshot.cs b/src/RAG.Infrastructure/Persistence/Migrations/RagDbContextModelSnapshot.cs index 60bc7f5..e9b52fa 100644 --- a/src/RAG.Infrastructure/Persistence/Migrations/RagDbContextModelSnapshot.cs +++ b/src/RAG.Infrastructure/Persistence/Migrations/RagDbContextModelSnapshot.cs @@ -20,8 +20,271 @@ namespace RAG.Infrastructure.Persistence.Migrations .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TokenUsage") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.ToTable("chat_messages", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KnowledgeBaseId") + .HasColumnType("uuid"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("KnowledgeBaseId"); + + b.HasIndex("UserId"); + + b.ToTable("conversations", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkCount") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("KnowledgeBaseId") + .HasColumnType("uuid"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("KnowledgeBaseId"); + + b.ToTable("documents", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkIndex") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("TokenCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId"); + + b.ToTable("document_chunks", (string)null); + }); + + modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChunkOverlap") + .HasColumnType("integer"); + + b.Property("ChunkSize") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("EmbeddingModel") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OperatorIP") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("knowledge_bases", (string)null); + }); + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => { b.Property("Id") @@ -394,6 +657,56 @@ namespace RAG.Infrastructure.Persistence.Migrations b.ToTable("user_roles", (string)null); }); + modelBuilder.Entity("RAG.Domain.Entities.ChatMessage", b => + { + b.HasOne("RAG.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase") + .WithMany() + .HasForeignKey("KnowledgeBaseId"); + + b.HasOne("RAG.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KnowledgeBase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.HasOne("RAG.Domain.Entities.KnowledgeBase", "KnowledgeBase") + .WithMany("Documents") + .HasForeignKey("KnowledgeBaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("KnowledgeBase"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.DocumentChunk", b => + { + b.HasOne("RAG.Domain.Entities.Document", "Document") + .WithMany("Chunks") + .HasForeignKey("DocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Document"); + }); + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => { b.HasOne("RAG.Domain.Entities.Menu", "Parent") @@ -453,6 +766,21 @@ namespace RAG.Infrastructure.Persistence.Migrations b.Navigation("User"); }); + modelBuilder.Entity("RAG.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.Document", b => + { + b.Navigation("Chunks"); + }); + + modelBuilder.Entity("RAG.Domain.Entities.KnowledgeBase", b => + { + b.Navigation("Documents"); + }); + modelBuilder.Entity("RAG.Domain.Entities.Menu", b => { b.Navigation("Children"); diff --git a/src/RAG.Infrastructure/Persistence/RagDbContext.cs b/src/RAG.Infrastructure/Persistence/RagDbContext.cs index 8e9244d..01a25be 100644 --- a/src/RAG.Infrastructure/Persistence/RagDbContext.cs +++ b/src/RAG.Infrastructure/Persistence/RagDbContext.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; +using Pgvector.EntityFrameworkCore; using RAG.Domain.Common; using RAG.Domain.Entities; @@ -16,9 +17,15 @@ public class RagDbContext : DbContext public DbSet RolePermissions => Set(); public DbSet RefreshTokens => Set(); public DbSet Menus => Set(); + public DbSet Conversations => Set(); + public DbSet ChatMessages => Set(); + public DbSet KnowledgeBases => Set(); + public DbSet Documents => Set(); + public DbSet DocumentChunks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.HasPostgresExtension("vector"); modelBuilder.ApplyConfigurationsFromAssembly(typeof(RagDbContext).Assembly); // 为所有实现 ISoftDelete 的实体自动注册全局查询过滤器:e => !e.IsDeleted diff --git a/src/RAG.Infrastructure/Persistence/SeedData.cs b/src/RAG.Infrastructure/Persistence/SeedData.cs index 09b1a7f..80bb2f8 100644 --- a/src/RAG.Infrastructure/Persistence/SeedData.cs +++ b/src/RAG.Infrastructure/Persistence/SeedData.cs @@ -1,4 +1,5 @@ using RAG.Domain.Entities; +using Microsoft.EntityFrameworkCore; namespace RAG.Infrastructure.Persistence; @@ -6,71 +7,83 @@ public static class SeedData { public static async Task SeedAsync(RagDbContext db) { - if (db.Roles.Any()) return; - - var permissions = new[] + // 权限 + if (!await db.Permissions.AnyAsync()) { - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "查看用户", Code = "user:read", Group = "UserManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "创建用户", Code = "user:create", Group = "UserManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Name = "更新用户", Code = "user:update", Group = "UserManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Name = "删除用户", Code = "user:delete", Group = "UserManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Name = "分配角色", Code = "user:assign-role", Group = "UserManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Name = "查看角色", Code = "role:read", Group = "RoleManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Name = "创建角色", Code = "role:create", Group = "RoleManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Name = "更新角色", Code = "role:update", Group = "RoleManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Name = "删除角色", Code = "role:delete", Group = "RoleManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000010"), Name = "分配权限", Code = "role:assign-permission", Group = "RoleManagement" }, - new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000011"), Name = "查看权限", Code = "permission:read", Group = "SystemManagement" }, - }; + var permissions = new[] + { + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), Name = "查看用户", Code = "user:read", Group = "UserManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), Name = "创建用户", Code = "user:create", Group = "UserManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), Name = "更新用户", Code = "user:update", Group = "UserManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000004"), Name = "删除用户", Code = "user:delete", Group = "UserManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000005"), Name = "分配角色", Code = "user:assign-role", Group = "UserManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000006"), Name = "查看角色", Code = "role:read", Group = "RoleManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000007"), Name = "创建角色", Code = "role:create", Group = "RoleManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000008"), Name = "更新角色", Code = "role:update", Group = "RoleManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000009"), Name = "删除角色", Code = "role:delete", Group = "RoleManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000010"), Name = "分配权限", Code = "role:assign-permission", Group = "RoleManagement" }, + new Permission { Id = Guid.Parse("10000000-0000-0000-0000-000000000011"), Name = "查看权限", Code = "permission:read", Group = "SystemManagement" }, + }; + await db.Permissions.AddRangeAsync(permissions); + await db.SaveChangesAsync(); + } - await db.Permissions.AddRangeAsync(permissions); - - var superAdminRole = new Role + // 角色 + if (!await db.Roles.AnyAsync()) { - Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), - Name = "SuperAdmin", - Description = "超级管理员" - }; - var adminRole = new Role + var superAdminRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), Name = "SuperAdmin", Description = "超级管理员" }; + var adminRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), Name = "Admin", Description = "管理员" }; + var userRole = new Role { Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), Name = "User", Description = "普通用户" }; + await db.Roles.AddRangeAsync(superAdminRole, adminRole, userRole); + await db.SaveChangesAsync(); + } + + // 角色-权限关联 + if (!await db.RolePermissions.AnyAsync()) { - Id = Guid.Parse("20000000-0000-0000-0000-000000000002"), - Name = "Admin", - Description = "管理员" - }; - var userRole = new Role + var superAdminRoleId = Guid.Parse("20000000-0000-0000-0000-000000000001"); + var adminRoleId = Guid.Parse("20000000-0000-0000-0000-000000000002"); + var userRoleId = Guid.Parse("20000000-0000-0000-0000-000000000003"); + + var allPerms = await db.Permissions.ToListAsync(); + await db.RolePermissions.AddRangeAsync(allPerms.Select(p => new RolePermission { RoleId = superAdminRoleId, PermissionId = p.Id })); + await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "user:create" or "user:update" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = adminRoleId, PermissionId = p.Id })); + await db.RolePermissions.AddRangeAsync(allPerms.Where(p => p.Code is "user:read" or "role:read" or "permission:read").Select(p => new RolePermission { RoleId = userRoleId, PermissionId = p.Id })); + await db.SaveChangesAsync(); + } + + // 管理员用户 + var adminUserId = Guid.Parse("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"); + var existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Id == adminUserId); + if (existingAdmin == null) { - Id = Guid.Parse("20000000-0000-0000-0000-000000000003"), - Name = "User", - Description = "普通用户" - }; + var seededAdminId = Guid.Parse("30000000-0000-0000-0000-000000000001"); + existingAdmin = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin"); + if (existingAdmin == null) + { + existingAdmin = new User + { + Id = seededAdminId, + Username = "admin", + Email = "admin@rag.local", + PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123") + }; + await db.Users.AddAsync(existingAdmin); + await db.SaveChangesAsync(); + } + } - await db.Roles.AddRangeAsync(superAdminRole, adminRole, userRole); - - var allRolePerms = permissions.Select(p => new RolePermission { RoleId = superAdminRole.Id, PermissionId = p.Id }).ToList(); - await db.RolePermissions.AddRangeAsync(allRolePerms); - - var adminPerms = permissions.Where(p => p.Code is "user:read" or "user:create" or "user:update" or "role:read" or "permission:read") - .Select(p => new RolePermission { RoleId = adminRole.Id, PermissionId = p.Id }).ToList(); - await db.RolePermissions.AddRangeAsync(adminPerms); - - var userPerms = permissions.Where(p => p.Code is "user:read" or "role:read" or "permission:read") - .Select(p => new RolePermission { RoleId = userRole.Id, PermissionId = p.Id }).ToList(); - await db.RolePermissions.AddRangeAsync(userPerms); - - var adminUser = new User + // 确保管理员有 SuperAdmin 角色 + var superAdminId = Guid.Parse("20000000-0000-0000-0000-000000000001"); + var hasRole = await db.UserRoles.AnyAsync(ur => ur.UserId == existingAdmin.Id && ur.RoleId == superAdminId); + if (!hasRole) { - Id = Guid.Parse("30000000-0000-0000-0000-000000000001"), - Username = "admin", - Email = "admin@rag.local", - PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin123") - }; - await db.Users.AddAsync(adminUser); - await db.UserRoles.AddAsync(new UserRole { UserId = adminUser.Id, RoleId = superAdminRole.Id }); + await db.UserRoles.AddAsync(new UserRole { UserId = existingAdmin.Id, RoleId = superAdminId }); + await db.SaveChangesAsync(); + } // ===== 菜单种子数据 ===== await SeedMenusAsync(db); - - await db.SaveChangesAsync(); } private static async Task SeedMenusAsync(RagDbContext db) @@ -102,89 +115,6 @@ public static class SeedData Title = "page.dashboard.workspace", Order = 2, ParentId = Guid.Parse("40000000-0000-0000-0000-000000000001") }, - - // ── Demos ── - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000010"), - Name = "Demos", Path = "/demos", Redirect = "/demos/access", - Title = "demos.title", Icon = "ic:baseline-view-in-ar", - Order = 1000, Component = null, KeepAlive = true - }, - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000011"), - Name = "AccessDemos", Path = "/demosaccess", Redirect = "/demos/access/page-control", - Title = "demos.access.backendPermissions", Icon = "mdi:cloud-key-outline", - Order = 1, Component = null, - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000010") - }, - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000012"), - Name = "AccessPageControlDemo", Path = "/demos/access/page-control", - Component = "/demos/access/index", - Title = "demos.access.pageAccess", Icon = "mdi:page-previous-outline", - Order = 1, - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000013"), - Name = "AccessButtonControlDemo", Path = "/demos/access/button-control", - Component = "/demos/access/button-control", - Title = "demos.access.buttonControl", Icon = "mdi:button-cursor", - Order = 2, - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000014"), - Name = "AccessMenuVisible403Demo", Path = "/demos/access/menu-visible-403", - Component = "/demos/access/menu-visible-403", - Title = "demos.access.menuVisible403", Icon = "mdi:button-cursor", - Order = 3, MenuVisibleWithForbidden = true, Authority = "no-body", - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - // SuperAdmin 专属页面 - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000015"), - Name = "AccessSuperVisibleDemo", Path = "/demos/access/super-visible", - Component = "/demos/access/super-visible", - Title = "demos.access.superVisible", Icon = "mdi:button-cursor", - Order = 4, Authority = "SuperAdmin", - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - // Admin 专属页面 - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000016"), - Name = "AccessAdminVisibleDemo", Path = "/demos/access/admin-visible", - Component = "/demos/access/admin-visible", - Title = "demos.access.adminVisible", Icon = "mdi:button-cursor", - Order = 4, Authority = "Admin", - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - // User 专属页面 - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000017"), - Name = "AccessUserVisibleDemo", Path = "/demos/access/user-visible", - Component = "/demos/access/user-visible", - Title = "demos.access.userVisible", Icon = "mdi:button-cursor", - Order = 4, Authority = "User", - ParentId = Guid.Parse("40000000-0000-0000-0000-000000000011") - }, - - // ── About ── - new() - { - Id = Guid.Parse("40000000-0000-0000-0000-000000000020"), - Name = "About", Path = "/about", Component = "_core/about/index", - Title = "demos.vben.about", Icon = "lucide:copyright", - Order = 9999 - }, }; await db.Menus.AddRangeAsync(menus); diff --git a/src/RAG.Infrastructure/RAG.Infrastructure.csproj b/src/RAG.Infrastructure/RAG.Infrastructure.csproj index a46b4c0..23acf9d 100644 --- a/src/RAG.Infrastructure/RAG.Infrastructure.csproj +++ b/src/RAG.Infrastructure/RAG.Infrastructure.csproj @@ -6,6 +6,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -14,6 +15,7 @@ + diff --git a/src/RAG.Infrastructure/RAGInfrastructureModule.cs b/src/RAG.Infrastructure/RAGInfrastructureModule.cs index d50f3a8..7d9f657 100644 --- a/src/RAG.Infrastructure/RAGInfrastructureModule.cs +++ b/src/RAG.Infrastructure/RAGInfrastructureModule.cs @@ -1,7 +1,10 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Pgvector.EntityFrameworkCore; using RAG.Domain; +using RAG.Domain.Interfaces; +using RAG.Infrastructure.AI; using RAG.Infrastructure.Cache; using RAG.Infrastructure.Messaging; using RAG.Infrastructure.Persistence; @@ -24,7 +27,7 @@ public class RAGInfrastructureModule : AbpModule // DbContext 挂载审计拦截器,拦截器从 DI 容器解析 ICurrentUserContext services.AddDbContext((sp, options) => - options.UseNpgsql(config.GetConnectionString("Default")) + options.UseNpgsql(config.GetConnectionString("Default"), o => o.UseVector()) .AddInterceptors(sp.GetRequiredService())); // Scoped 生命周期:每个 HTTP 请求创建一个拦截器实例,确保 ICurrentUserContext 正确获取当前用户 @@ -32,5 +35,12 @@ public class RAGInfrastructureModule : AbpModule services.AddRedisCache(config); services.AddRabbitMq(config); + + // AI 服务注册 + services.Configure(config.GetSection(AiOptions.SectionName)); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); } }