feat: add AI chat, RAG Q&A, knowledge base, embeddings, document processing
- AI chat with SSE streaming (Microsoft Agent Framework + Qwen) - RAG Q&A with hybrid retrieval (vector + keyword RRF fusion) - Knowledge base CRUD with semantic text chunking - Embedding generation via Azure.AI.OpenAI / LM Studio - Document upload with chunked upload support - Redis caching for chat messages - Chunk/vector preview endpoints - gRPC auth service improvements - Removed demo menus, cleaned up seed data
This commit is contained in:
parent
9a403634b2
commit
67b030c3c5
141
CLAUDE.md
Normal file
141
CLAUDE.md
Normal file
@ -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<T>`
|
||||
> - Endpoint: `FastEndpoint<TReq, TRes>` + `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 <Name> --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<RAGApiModule>()`.
|
||||
|
||||
### 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<TReq, TRes>
|
||||
```
|
||||
|
||||
### 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<UserRole> 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<User>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<User> 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<UserDto>;
|
||||
|
||||
public class CreateUserCommandHandler(RagDbContext db) : IRequestHandler<CreateUserCommand, UserDto>
|
||||
{
|
||||
public async Task<UserDto> Handle(CreateUserCommand request, CancellationToken ct) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Endpoint
|
||||
|
||||
```csharp
|
||||
public class CreateUserEndpoint(IMediator mediator) : Endpoint<CreateUserRequest, UserDto>
|
||||
{
|
||||
public override void Configure() { Post("/users"); Permissions("user:create"); }
|
||||
public override async Task HandleAsync(CreateUserRequest req, CancellationToken ct) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Validation
|
||||
|
||||
```csharp
|
||||
public class CreateUserCommandValidator : AbstractValidator<CreateUserCommand>
|
||||
{
|
||||
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/`
|
||||
@ -1,5 +1,4 @@
|
||||
using RAG.Domain.Common;
|
||||
using RAG.Domain.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Auth.Queries;
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
using RAG.Domain.Common;
|
||||
using RAG.Domain.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Auth.Queries;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
23
src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs
Normal file
23
src/RAG.Api/Endpoints/Chat/CreateConversationEndpoint.cs
Normal file
@ -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<CreateConversationRequest, ConversationDto>
|
||||
{
|
||||
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);
|
||||
22
src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Chat/DeleteConversationEndpoint.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Chat.Commands;
|
||||
|
||||
namespace RAG.Api.Endpoints.Chat;
|
||||
|
||||
public class DeleteConversationEndpoint(IMediator mediator) : Endpoint<DeleteConversationEndpointRequest>
|
||||
{
|
||||
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);
|
||||
23
src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs
Normal file
23
src/RAG.Api/Endpoints/Chat/GetConversationDetailEndpoint.cs
Normal file
@ -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<GetConversationDetailRequest, ConversationDetailDto>
|
||||
{
|
||||
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);
|
||||
21
src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs
Normal file
21
src/RAG.Api/Endpoints/Chat/GetConversationsEndpoint.cs
Normal file
@ -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<List<ConversationDto>>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
24
src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs
Normal file
24
src/RAG.Api/Endpoints/Chat/SendMessageEndpoint.cs
Normal file
@ -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<SendMessageRequest, SendMessageResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/chat/conversations/{ConversationId}/messages");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(SendMessageRequest req, CancellationToken ct)
|
||||
{
|
||||
var conversationId = Route<Guid>("ConversationId");
|
||||
var result = await mediator.Send(new SendMessageCommand(conversationId, req.Content), ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public record SendMessageRequest(string Content);
|
||||
79
src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs
Normal file
79
src/RAG.Api/Endpoints/Chat/StreamMessageEndpoint.cs
Normal file
@ -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<StreamMessageRequest>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/chat/conversations/{ConversationId}/stream");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(StreamMessageRequest req, CancellationToken ct)
|
||||
{
|
||||
var conversationId = Route<Guid>("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);
|
||||
156
src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs
Normal file
156
src/RAG.Api/Endpoints/Document/ChunkUploadEndpoint.cs
Normal file
@ -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<int, string> UploadedChunks { get; } = [];
|
||||
}
|
||||
|
||||
public static class ChunkUploadStore
|
||||
{
|
||||
public static readonly ConcurrentDictionary<string, ChunkUploadSession> Sessions = [];
|
||||
}
|
||||
|
||||
// ===== 初始化 =====
|
||||
|
||||
public class InitChunkUploadRequest
|
||||
{
|
||||
public string FileName { get; set; } = default!;
|
||||
public long FileSize { get; set; }
|
||||
public int ChunkCount { get; set; }
|
||||
}
|
||||
|
||||
public class InitChunkUploadEndpoint : Endpoint<InitChunkUploadRequest, ChunkUploadSession>
|
||||
{
|
||||
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<Guid>("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<EmptyResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/documents/chunk-upload/{SessionId}/chunks/{ChunkIndex}");
|
||||
AllowAnonymous();
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var sessionId = Route<string>("SessionId");
|
||||
var chunkIndex = Route<int>("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<CompleteChunkUploadRequest, DocumentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/documents/chunk-upload/{SessionId}/complete");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CompleteChunkUploadRequest req, CancellationToken ct)
|
||||
{
|
||||
var sessionId = Route<string>("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);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Document/GetDocumentChunksEndpoint.cs
Normal file
@ -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<List<ChunkPreviewDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/documents/{DocumentId}/chunks");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var docId = Route<Guid>("DocumentId");
|
||||
var result = await mediator.Send(new GetDocumentChunksQuery(docId), ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Document/ListDocumentsEndpoint.cs
Normal file
@ -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<List<DocumentDto>>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/knowledge-bases/{KnowledgeBaseId}/documents");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(CancellationToken ct)
|
||||
{
|
||||
var kbId = Route<Guid>("KnowledgeBaseId");
|
||||
var result = await mediator.Send(new GetDocumentsQuery(kbId), ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/Document/ProcessDocumentEndpoint.cs
Normal file
@ -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<EmptyRequest, ProcessDocumentResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/documents/{DocumentId}/process");
|
||||
AllowAnonymous();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(EmptyRequest req, CancellationToken ct)
|
||||
{
|
||||
var docId = Route<Guid>("DocumentId");
|
||||
var result = await mediator.Send(new ProcessDocumentCommand(docId), ct);
|
||||
await Send.OkAsync(result, ct);
|
||||
}
|
||||
}
|
||||
38
src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs
Normal file
38
src/RAG.Api/Endpoints/Document/UploadDocumentEndpoint.cs
Normal file
@ -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<UploadDocumentRequest, DocumentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/knowledge-bases/{KnowledgeBaseId}/documents");
|
||||
AllowAnonymous();
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UploadDocumentRequest req, CancellationToken ct)
|
||||
{
|
||||
var kbId = Route<Guid>("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);
|
||||
23
src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs
Normal file
23
src/RAG.Api/Endpoints/Embedding/EmbedBatchEndpoint.cs
Normal file
@ -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<EmbedBatchRequest, EmbeddingBatchResponse>
|
||||
{
|
||||
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<string> Texts);
|
||||
23
src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs
Normal file
23
src/RAG.Api/Endpoints/Embedding/EmbedTextEndpoint.cs
Normal file
@ -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<EmbedTextRequest, EmbeddingResponse>
|
||||
{
|
||||
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);
|
||||
22
src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/KnowledgeBase/CreateKBEndpoint.cs
Normal file
@ -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<CreateKnowledgeBaseRequest, KnowledgeBaseDto>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs
Normal file
22
src/RAG.Api/Endpoints/KnowledgeBase/DeleteKBEndpoint.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.KnowledgeBase.Commands;
|
||||
|
||||
namespace RAG.Api.Endpoints.KnowledgeBase;
|
||||
|
||||
public class DeleteKBEndpoint(IMediator mediator) : Endpoint<DeleteKBEndpointRequest>
|
||||
{
|
||||
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);
|
||||
21
src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs
Normal file
21
src/RAG.Api/Endpoints/KnowledgeBase/GetKBsEndpoint.cs
Normal file
@ -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<List<KnowledgeBaseDto>>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
using RAG.Domain.Common;
|
||||
using RAG.Domain.Exceptions;
|
||||
using FastEndpoints;
|
||||
using MediatR;
|
||||
using RAG.Application.Menus.DTOs;
|
||||
|
||||
21
src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs
Normal file
21
src/RAG.Api/Endpoints/RAG/RAGQueryEndpoint.cs
Normal file
@ -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<RAGQueryRequest, RAGQueryResponse>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
169
src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs
Normal file
169
src/RAG.Api/Endpoints/RAG/RAGStreamEndpoint.cs
Normal file
@ -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<RAGQueryRequest>
|
||||
{
|
||||
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<string> ExtractKeywords(string question)
|
||||
{
|
||||
var stopWords = new HashSet<string>
|
||||
{
|
||||
"的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都",
|
||||
"这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪",
|
||||
"哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会",
|
||||
"what", "how", "why", "is", "the", "a", "an", "of", "to", "in"
|
||||
};
|
||||
|
||||
var words = new List<string>();
|
||||
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<string> keywords)
|
||||
{
|
||||
if (keywords.Count == 0) return 0;
|
||||
var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase));
|
||||
return (double)matched / keywords.Count;
|
||||
}
|
||||
}
|
||||
@ -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<GetUsersResponse> 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<RagDbContext>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -6,10 +6,6 @@ using RAG.Domain.Exceptions;
|
||||
|
||||
namespace RAG.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 全局异常中间件,捕获所有未处理异常并统一返回 { code, message, data } 格式。
|
||||
/// 必须注册在 ApiResponseMiddleware 之前,确保异常不会穿透到 ASP.NET Core 默认错误页。
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ConversationDto>;
|
||||
|
||||
public class CreateConversationCommandHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<CreateConversationCommand, ConversationDto>
|
||||
{
|
||||
public async Task<ConversationDto> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Unit>;
|
||||
|
||||
public class DeleteConversationCommandHandler(RagDbContext db, IChatMessageCache cache)
|
||||
: IRequestHandler<DeleteConversationCommand, Unit>
|
||||
{
|
||||
public async Task<Unit> 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;
|
||||
}
|
||||
}
|
||||
62
src/RAG.Application/Chat/Commands/SendMessageCommand.cs
Normal file
62
src/RAG.Application/Chat/Commands/SendMessageCommand.cs
Normal file
@ -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<SendMessageResponse>;
|
||||
|
||||
public class SendMessageCommandHandler(RagDbContext db, IAIChatAgent chatAgent, IChatMessageCache cache)
|
||||
: IRequestHandler<SendMessageCommand, SendMessageResponse>
|
||||
{
|
||||
public async Task<SendMessageResponse> 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);
|
||||
}
|
||||
}
|
||||
10
src/RAG.Application/Chat/DTOs/ChatDTOs.cs
Normal file
10
src/RAG.Application/Chat/DTOs/ChatDTOs.cs
Normal file
@ -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<ChatMessageDto> Messages);
|
||||
|
||||
public record ChatMessageDto(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt);
|
||||
|
||||
public record SendMessageResponse(Guid MessageId, string Content);
|
||||
@ -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<ConversationDetailDto>;
|
||||
|
||||
public class GetConversationDetailQueryHandler(RagDbContext db, IChatMessageCache cache)
|
||||
: IRequestHandler<GetConversationDetailQuery, ConversationDetailDto>
|
||||
{
|
||||
public async Task<ConversationDetailDto> 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);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Application/Chat/Queries/GetConversationsQuery.cs
Normal file
22
src/RAG.Application/Chat/Queries/GetConversationsQuery.cs
Normal file
@ -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<List<ConversationDto>>;
|
||||
|
||||
public class GetConversationsQueryHandler(RagDbContext db, ICurrentUserContext userContext) : IRequestHandler<GetConversationsQuery, List<ConversationDto>>
|
||||
{
|
||||
public async Task<List<ConversationDto>> 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);
|
||||
}
|
||||
}
|
||||
22
src/RAG.Application/Chat/Validators/ChatValidators.cs
Normal file
22
src/RAG.Application/Chat/Validators/ChatValidators.cs
Normal file
@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace RAG.Application.Chat.Validators;
|
||||
|
||||
public class CreateConversationCommandValidator : AbstractValidator<Commands.CreateConversationCommand>
|
||||
{
|
||||
public CreateConversationCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Title).NotEmpty().WithMessage("会话标题不能为空")
|
||||
.MaximumLength(200).WithMessage("会话标题不能超过200个字符");
|
||||
}
|
||||
}
|
||||
|
||||
public class SendMessageCommandValidator : AbstractValidator<Commands.SendMessageCommand>
|
||||
{
|
||||
public SendMessageCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ConversationId).NotEmpty().WithMessage("会话ID不能为空");
|
||||
RuleFor(x => x.Content).NotEmpty().WithMessage("消息内容不能为空")
|
||||
.MaximumLength(10000).WithMessage("消息内容不能超过10000个字符");
|
||||
}
|
||||
}
|
||||
@ -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<ProcessDocumentResponse>;
|
||||
|
||||
public class ProcessDocumentCommandHandler(RagDbContext db, ITextChunker chunker, IEmbeddingService embeddingService)
|
||||
: IRequestHandler<ProcessDocumentCommand, ProcessDocumentResponse>
|
||||
{
|
||||
public async Task<ProcessDocumentResponse> 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<DocumentChunk>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<DocumentDto>;
|
||||
|
||||
public class UploadDocumentCommandHandler(RagDbContext db) : IRequestHandler<UploadDocumentCommand, DocumentDto>
|
||||
{
|
||||
public async Task<DocumentDto> 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);
|
||||
}
|
||||
}
|
||||
16
src/RAG.Application/Document/DTOs/DocumentDTOs.cs
Normal file
16
src/RAG.Application/Document/DTOs/DocumentDTOs.cs
Normal file
@ -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<float>? EmbeddingPreview
|
||||
);
|
||||
@ -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<List<ChunkPreviewDto>>;
|
||||
|
||||
public class GetDocumentChunksQueryHandler(RagDbContext db)
|
||||
: IRequestHandler<GetDocumentChunksQuery, List<ChunkPreviewDto>>
|
||||
{
|
||||
public async Task<List<ChunkPreviewDto>> 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<Guid, (bool Has, int? Dim, List<float>? 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<float>? 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();
|
||||
}
|
||||
}
|
||||
21
src/RAG.Application/Document/Queries/GetDocumentsQuery.cs
Normal file
21
src/RAG.Application/Document/Queries/GetDocumentsQuery.cs
Normal file
@ -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<List<DocumentDto>>;
|
||||
|
||||
public class GetDocumentsQueryHandler(RagDbContext db) : IRequestHandler<GetDocumentsQuery, List<DocumentDto>>
|
||||
{
|
||||
public async Task<List<DocumentDto>> 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace RAG.Application.Document.Validators;
|
||||
|
||||
public class UploadDocumentCommandValidator : AbstractValidator<Commands.UploadDocumentCommand>
|
||||
{
|
||||
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<Commands.ProcessDocumentCommand>
|
||||
{
|
||||
public ProcessDocumentCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.DocumentId).NotEmpty().WithMessage("文档ID不能为空");
|
||||
}
|
||||
}
|
||||
18
src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs
Normal file
18
src/RAG.Application/Embedding/Commands/EmbedBatchCommand.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using MediatR;
|
||||
using RAG.Application.Embedding.DTOs;
|
||||
using RAG.Domain.Interfaces;
|
||||
|
||||
namespace RAG.Application.Embedding.Commands;
|
||||
|
||||
public record EmbedBatchCommand(List<string> Texts) : IRequest<EmbeddingBatchResponse>;
|
||||
|
||||
public class EmbedBatchCommandHandler(IEmbeddingService embeddingService)
|
||||
: IRequestHandler<EmbedBatchCommand, EmbeddingBatchResponse>
|
||||
{
|
||||
public async Task<EmbeddingBatchResponse> 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);
|
||||
}
|
||||
}
|
||||
17
src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs
Normal file
17
src/RAG.Application/Embedding/Commands/EmbedTextCommand.cs
Normal file
@ -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<EmbeddingResponse>;
|
||||
|
||||
public class EmbedTextCommandHandler(IEmbeddingService embeddingService)
|
||||
: IRequestHandler<EmbedTextCommand, EmbeddingResponse>
|
||||
{
|
||||
public async Task<EmbeddingResponse> Handle(EmbedTextCommand request, CancellationToken ct)
|
||||
{
|
||||
var vector = await embeddingService.EmbedAsync(request.Text, ct);
|
||||
return new EmbeddingResponse(vector.ToList(), vector.Length);
|
||||
}
|
||||
}
|
||||
5
src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs
Normal file
5
src/RAG.Application/Embedding/DTOs/EmbeddingDTOs.cs
Normal file
@ -0,0 +1,5 @@
|
||||
namespace RAG.Application.Embedding.DTOs;
|
||||
|
||||
public record EmbeddingResponse(List<float> Vector, int Dimensions);
|
||||
|
||||
public record EmbeddingBatchResponse(List<List<float>> Vectors, int Dimensions);
|
||||
@ -0,0 +1,21 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace RAG.Application.Embedding.Validators;
|
||||
|
||||
public class EmbedTextCommandValidator : AbstractValidator<Commands.EmbedTextCommand>
|
||||
{
|
||||
public EmbedTextCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Text).NotEmpty().WithMessage("文本内容不能为空")
|
||||
.MaximumLength(10000).WithMessage("单条文本不能超过10000个字符");
|
||||
}
|
||||
}
|
||||
|
||||
public class EmbedBatchCommandValidator : AbstractValidator<Commands.EmbedBatchCommand>
|
||||
{
|
||||
public EmbedBatchCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Texts).NotEmpty().WithMessage("文本列表不能为空")
|
||||
.Must(t => t.Count <= 100).WithMessage("批量文本不能超过100条");
|
||||
}
|
||||
}
|
||||
@ -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<KnowledgeBaseDto>;
|
||||
|
||||
public class CreateKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<CreateKnowledgeBaseCommand, KnowledgeBaseDto>
|
||||
{
|
||||
public async Task<KnowledgeBaseDto> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Unit>;
|
||||
|
||||
public class DeleteKnowledgeBaseCommandHandler(RagDbContext db) : IRequestHandler<DeleteKnowledgeBaseCommand, Unit>
|
||||
{
|
||||
public async Task<Unit> 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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<List<KnowledgeBaseDto>>;
|
||||
|
||||
public class GetKnowledgeBasesQueryHandler(RagDbContext db) : IRequestHandler<GetKnowledgeBasesQuery, List<KnowledgeBaseDto>>
|
||||
{
|
||||
public async Task<List<KnowledgeBaseDto>> 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);
|
||||
}
|
||||
}
|
||||
13
src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs
Normal file
13
src/RAG.Application/KnowledgeBase/Validators/KBValidators.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace RAG.Application.KnowledgeBase.Validators;
|
||||
|
||||
public class CreateKnowledgeBaseCommandValidator : AbstractValidator<Commands.CreateKnowledgeBaseCommand>
|
||||
{
|
||||
public CreateKnowledgeBaseCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().WithMessage("知识库名称不能为空")
|
||||
.MaximumLength(200).WithMessage("名称不能超过200个字符");
|
||||
RuleFor(x => x.Description).MaximumLength(1000).When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
165
src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs
Normal file
165
src/RAG.Application/RagQA/Commands/RAGQueryCommand.cs
Normal file
@ -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<RAGQueryResponse>;
|
||||
|
||||
public class RAGQueryCommandHandler(RagDbContext db, IEmbeddingService embeddingService, IAIChatAgent chatAgent)
|
||||
: IRequestHandler<RAGQueryCommand, RAGQueryResponse>
|
||||
{
|
||||
private const int VectorTopK = 20;
|
||||
private const int FinalTopK = 5;
|
||||
private const double MinSimilarity = 0.3;
|
||||
|
||||
public async Task<RAGQueryResponse> 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);
|
||||
}
|
||||
|
||||
/// <summary>提取中文/英文关键词(简单分词)</summary>
|
||||
private static List<string> ExtractKeywords(string question)
|
||||
{
|
||||
// 去除常见疑问词
|
||||
var stopWords = new HashSet<string>
|
||||
{
|
||||
"的", "了", "是", "在", "有", "和", "与", "或", "不", "也", "都",
|
||||
"这", "那", "个", "一", "什么", "怎么", "如何", "为什么", "哪",
|
||||
"哪些", "吗", "呢", "吧", "啊", "请", "能", "可以", "会",
|
||||
"what", "how", "why", "is", "the", "a", "an", "of", "to", "in"
|
||||
};
|
||||
|
||||
var words = new List<string>();
|
||||
|
||||
// 提取中文词组(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();
|
||||
}
|
||||
|
||||
/// <summary>计算关键词匹配得分(0-1)</summary>
|
||||
private static double KeywordMatchScore(string content, List<string> keywords)
|
||||
{
|
||||
if (keywords.Count == 0) return 0;
|
||||
var matched = keywords.Count(kw => content.Contains(kw, StringComparison.OrdinalIgnoreCase));
|
||||
return (double)matched / keywords.Count;
|
||||
}
|
||||
}
|
||||
7
src/RAG.Application/RagQA/DTOs/RAGDTOs.cs
Normal file
7
src/RAG.Application/RagQA/DTOs/RAGDTOs.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace RAG.Application.RagQA.DTOs;
|
||||
|
||||
public record RAGQueryRequest(Guid KnowledgeBaseId, string Question);
|
||||
|
||||
public record RAGQueryResponse(string Answer, List<SourceChunk> Sources);
|
||||
|
||||
public record SourceChunk(Guid DocumentId, string DocumentTitle, string Content, double Similarity);
|
||||
13
src/RAG.Application/RagQA/Validators/RAGValidators.cs
Normal file
13
src/RAG.Application/RagQA/Validators/RAGValidators.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace RAG.Application.RagQA.Validators;
|
||||
|
||||
public class RAGQueryCommandValidator : AbstractValidator<Commands.RAGQueryCommand>
|
||||
{
|
||||
public RAGQueryCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.KnowledgeBaseId).NotEmpty().WithMessage("知识库ID不能为空");
|
||||
RuleFor(x => x.Question).NotEmpty().WithMessage("问题不能为空")
|
||||
.MaximumLength(5000).WithMessage("问题不能超过5000个字符");
|
||||
}
|
||||
}
|
||||
@ -12,4 +12,7 @@ public abstract class BaseEntity<TId> where TId : struct
|
||||
/// <summary>
|
||||
/// 实体基类,默认使用 Guid 作为主键。项目中大部分实体继承此类。
|
||||
/// </summary>
|
||||
public abstract class BaseEntity : BaseEntity<Guid> { }
|
||||
public abstract class BaseEntity : BaseEntity<Guid>
|
||||
{
|
||||
public BaseEntity() => Id = Guid.NewGuid();
|
||||
}
|
||||
|
||||
21
src/RAG.Domain/Entities/ChatMessage.cs
Normal file
21
src/RAG.Domain/Entities/ChatMessage.cs
Normal file
@ -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!;
|
||||
}
|
||||
27
src/RAG.Domain/Entities/Conversation.cs
Normal file
27
src/RAG.Domain/Entities/Conversation.cs
Normal file
@ -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<ChatMessage> Messages { get; set; } = [];
|
||||
}
|
||||
32
src/RAG.Domain/Entities/Document.cs
Normal file
32
src/RAG.Domain/Entities/Document.cs
Normal file
@ -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<DocumentChunk> Chunks { get; set; } = [];
|
||||
}
|
||||
22
src/RAG.Domain/Entities/DocumentChunk.cs
Normal file
22
src/RAG.Domain/Entities/DocumentChunk.cs
Normal file
@ -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!;
|
||||
}
|
||||
29
src/RAG.Domain/Entities/KnowledgeBase.cs
Normal file
29
src/RAG.Domain/Entities/KnowledgeBase.cs
Normal file
@ -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<Document> Documents { get; set; } = [];
|
||||
}
|
||||
8
src/RAG.Domain/Enums/ChatRole.cs
Normal file
8
src/RAG.Domain/Enums/ChatRole.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace RAG.Domain.Enums;
|
||||
|
||||
public enum ChatRole
|
||||
{
|
||||
System = 0,
|
||||
User = 1,
|
||||
Assistant = 2
|
||||
}
|
||||
9
src/RAG.Domain/Enums/DocumentStatus.cs
Normal file
9
src/RAG.Domain/Enums/DocumentStatus.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace RAG.Domain.Enums;
|
||||
|
||||
public enum DocumentStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Processing = 1,
|
||||
Completed = 2,
|
||||
Failed = 3
|
||||
}
|
||||
7
src/RAG.Domain/Enums/KnowledgeBaseStatus.cs
Normal file
7
src/RAG.Domain/Enums/KnowledgeBaseStatus.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace RAG.Domain.Enums;
|
||||
|
||||
public enum KnowledgeBaseStatus
|
||||
{
|
||||
Active = 0,
|
||||
Inactive = 1
|
||||
}
|
||||
7
src/RAG.Domain/Interfaces/IAIChatAgent.cs
Normal file
7
src/RAG.Domain/Interfaces/IAIChatAgent.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace RAG.Domain.Interfaces;
|
||||
|
||||
public interface IAIChatAgent
|
||||
{
|
||||
Task<string> RunAsync(string prompt, CancellationToken ct = default);
|
||||
IAsyncEnumerable<string> RunStreamingAsync(string prompt, CancellationToken ct = default);
|
||||
}
|
||||
18
src/RAG.Domain/Interfaces/IChatMessageCache.cs
Normal file
18
src/RAG.Domain/Interfaces/IChatMessageCache.cs
Normal file
@ -0,0 +1,18 @@
|
||||
namespace RAG.Domain.Interfaces;
|
||||
|
||||
public interface IChatMessageCache
|
||||
{
|
||||
/// <summary>获取会话的消息列表(缓存 miss 时返回 null)</summary>
|
||||
Task<List<CachedChatMessage>?> GetMessagesAsync(Guid conversationId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>设置会话消息缓存</summary>
|
||||
Task SetMessagesAsync(Guid conversationId, List<CachedChatMessage> messages, CancellationToken ct = default);
|
||||
|
||||
/// <summary>追加一条消息到缓存</summary>
|
||||
Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct = default);
|
||||
|
||||
/// <summary>删除会话缓存</summary>
|
||||
Task RemoveAsync(Guid conversationId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public record CachedChatMessage(Guid Id, string Role, string Content, int? TokenUsage, DateTime CreatedAt);
|
||||
7
src/RAG.Domain/Interfaces/IEmbeddingService.cs
Normal file
7
src/RAG.Domain/Interfaces/IEmbeddingService.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace RAG.Domain.Interfaces;
|
||||
|
||||
public interface IEmbeddingService
|
||||
{
|
||||
Task<float[]> EmbedAsync(string text, CancellationToken ct = default);
|
||||
Task<List<float[]>> EmbedBatchAsync(List<string> texts, CancellationToken ct = default);
|
||||
}
|
||||
8
src/RAG.Domain/Interfaces/ITextChunker.cs
Normal file
8
src/RAG.Domain/Interfaces/ITextChunker.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace RAG.Domain.Interfaces;
|
||||
|
||||
public record TextChunk(string Content, int Index, int StartPosition, int EndPosition);
|
||||
|
||||
public interface ITextChunker
|
||||
{
|
||||
List<TextChunk> Chunk(string content, int chunkSize = 500, int overlap = 50);
|
||||
}
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
|
||||
<PackageReference Include="Pgvector" Version="0.3.2" />
|
||||
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
14
src/RAG.Infrastructure/AI/AiOptions.cs
Normal file
14
src/RAG.Infrastructure/AI/AiOptions.cs
Normal file
@ -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;
|
||||
}
|
||||
43
src/RAG.Infrastructure/AI/ChatAgentService.cs
Normal file
43
src/RAG.Infrastructure/AI/ChatAgentService.cs
Normal file
@ -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<AiOptions> 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<string> RunAsync(string prompt, CancellationToken ct)
|
||||
{
|
||||
var response = await _agent.RunAsync(prompt, null, null, ct);
|
||||
return response.Text;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/RAG.Infrastructure/AI/EmbeddingService.cs
Normal file
34
src/RAG.Infrastructure/AI/EmbeddingService.cs
Normal file
@ -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<AiOptions> 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<float[]> EmbedAsync(string text, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.GenerateEmbeddingAsync(text, cancellationToken: ct);
|
||||
return response.Value.ToFloats().ToArray();
|
||||
}
|
||||
|
||||
public async Task<List<float[]>> EmbedBatchAsync(List<string> texts, CancellationToken ct)
|
||||
{
|
||||
var response = await _client.GenerateEmbeddingsAsync(texts, cancellationToken: ct);
|
||||
return response.Value.Select(e => e.ToFloats().ToArray()).ToList();
|
||||
}
|
||||
}
|
||||
127
src/RAG.Infrastructure/AI/TextChunker.cs
Normal file
127
src/RAG.Infrastructure/AI/TextChunker.cs
Normal file
@ -0,0 +1,127 @@
|
||||
using RAG.Domain.Interfaces;
|
||||
|
||||
namespace RAG.Infrastructure.AI;
|
||||
|
||||
public class TextChunker : ITextChunker
|
||||
{
|
||||
public List<TextChunk> Chunk(string content, int chunkSize = 500, int overlap = 50)
|
||||
{
|
||||
var chunks = new List<TextChunk>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string> 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<string> SplitSentences(string text)
|
||||
{
|
||||
var sentences = new List<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
38
src/RAG.Infrastructure/Cache/ChatMessageCache.cs
Normal file
38
src/RAG.Infrastructure/Cache/ChatMessageCache.cs
Normal file
@ -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<List<CachedChatMessage>?> GetMessagesAsync(Guid conversationId, CancellationToken ct)
|
||||
{
|
||||
var value = await _db.StringGetAsync(Key(conversationId));
|
||||
if (value.IsNullOrEmpty) return null;
|
||||
return JsonSerializer.Deserialize<List<CachedChatMessage>>((string)value!, JsonOptions);
|
||||
}
|
||||
|
||||
public async Task SetMessagesAsync(Guid conversationId, List<CachedChatMessage> 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));
|
||||
}
|
||||
}
|
||||
@ -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<ChatMessage>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ChatMessage> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Conversation>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Conversation> 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);
|
||||
}
|
||||
}
|
||||
@ -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<DocumentChunk>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<DocumentChunk> 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);
|
||||
}
|
||||
}
|
||||
@ -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<Document>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Document> 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);
|
||||
}
|
||||
}
|
||||
@ -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<KnowledgeBase>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<KnowledgeBase> 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);
|
||||
}
|
||||
}
|
||||
811
src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.Designer.cs
generated
Normal file
811
src/RAG.Infrastructure/Persistence/Migrations/20260519082511_AddAIEntities.Designer.cs
generated
Normal file
@ -0,0 +1,811 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using RAG.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RAG.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(RagDbContext))]
|
||||
[Migration("20260519082511_AddAIEntities")]
|
||||
partial class AddAIEntities
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TokenUsage")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<Guid?>("KnowledgeBaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<Guid>("KnowledgeBaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("TokenCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkOverlap")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ChunkSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("EmbeddingModel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ActiveIcon")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool?>("AffixTab")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Authority")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Component")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("HideInMenu")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool?>("KeepAlive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Link")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("MenuVisibleWithForbidden")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("NoBasicLayout")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Redirect")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Group")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("IsRevoked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("PermissionId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("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<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RoleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace RAG.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAIEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:PostgresExtension:vector", ",,");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "knowledge_bases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
EmbeddingModel = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
ChunkSize = table.Column<int>(type: "integer", nullable: false),
|
||||
ChunkOverlap = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
OperatorIP = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
KnowledgeBaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
OperatorIP = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
KnowledgeBaseId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Title = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
FileName = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
FilePath = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: false),
|
||||
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
ChunkCount = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||
OperatorIP = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Role = table.Column<int>(type: "integer", nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
TokenUsage = table.Column<int>(type: "integer", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
DocumentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Content = table.Column<string>(type: "text", nullable: false),
|
||||
ChunkIndex = table.Column<int>(type: "integer", nullable: false),
|
||||
TokenCount = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedBy = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(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)");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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", ",,");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("TokenUsage")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<Guid?>("KnowledgeBaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("FilePath")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<Guid>("KnowledgeBaseId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkIndex")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<Guid>("DocumentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("TokenCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("ChunkOverlap")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ChunkSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("EmbeddingModel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("OperatorIP")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("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");
|
||||
|
||||
@ -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<RolePermission> RolePermissions => Set<RolePermission>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<Menu> Menus => Set<Menu>();
|
||||
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||
public DbSet<ChatMessage> ChatMessages => Set<ChatMessage>();
|
||||
public DbSet<KnowledgeBase> KnowledgeBases => Set<KnowledgeBase>();
|
||||
public DbSet<Document> Documents => Set<Document>();
|
||||
public DbSet<DocumentChunk> DocumentChunks => Set<DocumentChunk>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(RagDbContext).Assembly);
|
||||
|
||||
// 为所有实现 ISoftDelete 的实体自动注册全局查询过滤器:e => !e.IsDeleted
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.6.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@ -14,6 +15,7 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.12.14" />
|
||||
<PackageReference Include="Volo.Abp.Core" Version="10.3.0" />
|
||||
|
||||
@ -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<RagDbContext>((sp, options) =>
|
||||
options.UseNpgsql(config.GetConnectionString("Default"))
|
||||
options.UseNpgsql(config.GetConnectionString("Default"), o => o.UseVector())
|
||||
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
|
||||
|
||||
// Scoped 生命周期:每个 HTTP 请求创建一个拦截器实例,确保 ICurrentUserContext 正确获取当前用户
|
||||
@ -32,5 +35,12 @@ public class RAGInfrastructureModule : AbpModule
|
||||
|
||||
services.AddRedisCache(config);
|
||||
services.AddRabbitMq(config);
|
||||
|
||||
// AI 服务注册
|
||||
services.Configure<AiOptions>(config.GetSection(AiOptions.SectionName));
|
||||
services.AddScoped<IAIChatAgent, ChatAgentService>();
|
||||
services.AddScoped<IEmbeddingService, EmbeddingService>();
|
||||
services.AddScoped<ITextChunker, TextChunker>();
|
||||
services.AddSingleton<IChatMessageCache, ChatMessageCache>();
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user