From 8ac593d36f335c89b22e3229ac9faf08ae371d8d Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Fri, 22 May 2026 01:30:41 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D1=83=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EndpointAuthorizationTests.cs | 2 +- .../Helpers/ApiWebApplicationFactory.cs | 4 +- .../Reviews/LlmAnalysisServiceTests.cs | 91 ++++++ .../Reviews/ReviewAnalysisWorkerTests.cs | 96 ++++++ .../Reviews/ReviewPromptServiceTests.cs | 60 +++- .../Reviews/ReviewServiceTests.cs | 119 +++++++ .../LlmProcessingBackgroundService.cs | 36 --- .../BackgroundServices/ReviewAnalysisQueue.cs | 21 ++ .../ReviewAnalysisWorker.cs | 96 ++++++ .../Controllers/ReviewsController.cs | 26 +- .../Options/ReviewAnalysisOptions.cs | 8 + backend/UniVerse.Api/Program.cs | 10 +- backend/UniVerse.Api/appsettings.json | 5 +- backend/UniVerse.Api/openapi.json | 79 +---- .../DTOs/Reviews/ReviewDtos.cs | 7 + .../Interfaces/ILlmAnalysisService.cs | 1 - .../Interfaces/ILlmClient.cs | 3 +- .../Interfaces/IReviewAnalysisQueue.cs | 6 + .../Interfaces/IReviewService.cs | 3 +- .../Mappings/MappingExtensions.cs | 2 +- backend/UniVerse.Domain/Entities/Review.cs | 1 + .../Configurations/ReviewConfiguration.cs | 1 + .../ExternalServices/LlmClient.cs | 33 +- .../20260522120000_ReviewLlmRawOutput.cs | 28 ++ .../Migrations/AppDbContextModelSnapshot.cs | 4 + .../Services/LlmAnalysisService.cs | 22 +- .../Services/ReviewService.cs | 42 ++- frontend/src/api/index.ts | 11 - frontend/src/api/mappers.ts | 3 +- frontend/src/api/types.ts | 4 +- .../src/components/layout/AppBottomNav.vue | 2 +- frontend/src/components/layout/AppSidebar.vue | 1 - frontend/src/router/index.ts | 2 +- .../src/views/admin/AdminDashboardView.vue | 21 +- .../src/views/admin/AdminLLMQueueView.vue | 170 ---------- frontend/src/views/admin/AdminReviewsView.vue | 295 +++++++++++++----- 36 files changed, 858 insertions(+), 457 deletions(-) create mode 100644 backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs create mode 100644 backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs create mode 100644 backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs delete mode 100644 backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs create mode 100644 backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs create mode 100644 backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs create mode 100644 backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs create mode 100644 backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs delete mode 100644 frontend/src/views/admin/AdminLLMQueueView.vue diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index d84abae..05f7254 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -121,10 +121,10 @@ public class EndpointAuthorizationTests : IClassFixture var stub = Substitute.For(); var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User", ReviewRating.Like, "Great!", ReviewLlmStatus.Pending, - null, null, null, null, DateTime.UtcNow); + null, null, null, null, null, DateTime.UtcNow); var pagedReviews = PagedResult.Create([reviewDto], 1, 1, 20); stub.CreateAsync(Arg.Any(), Arg.Any()).Returns(reviewDto); @@ -222,7 +222,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory stub.DeleteAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); stub.GetByLectureAsync(Arg.Any(), Arg.Any()).Returns(pagedReviews); stub.GetByUserAsync(Arg.Any(), Arg.Any()).Returns(pagedReviews); - stub.GetPendingAsync(Arg.Any()).Returns(pagedReviews); + stub.GetAllAsync(Arg.Any()).Returns(pagedReviews); stub.ReanalyzeAsync(Arg.Any()).Returns(Task.CompletedTask); return stub; } diff --git a/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs new file mode 100644 index 0000000..f93471c --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/LlmAnalysisServiceTests.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Reviews; + +public class LlmAnalysisServiceTests +{ + [Fact] + public async Task AnalyzeReviewAsync_SavesParsedAnalysisResult() + { + await using var db = CreateDbContext(); + await SeedPendingReviewAsync(db); + var llm = Substitute.For(); + llm.AnalyzeReviewAsync(Arg.Any(), Arg.Any()) + .Returns(new LlmReviewAnalysis( + 0.76, + "Положительный", + ["lecture structure", "practical examples"], + true, + "{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}")); + var gamification = Substitute.For(); + gamification.AwardCoinsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + gamification.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask); + var service = new LlmAnalysisService(db, llm, gamification, NullLogger.Instance); + + await service.AnalyzeReviewAsync(1); + + var review = await db.Reviews.SingleAsync(r => r.Id == 1); + Assert.Equal(ReviewLlmStatus.Analyzed, review.LlmStatus); + Assert.Equal(ReviewSentiment.Positive, review.Sentiment); + Assert.Equal(0.76, review.QualityScore); + Assert.True(review.IsInformative); + Assert.Equal(["lecture structure", "practical examples"], review.LlmTags!); + Assert.Equal("{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}", review.LlmRawOutput); + await gamification.Received(1).AwardCoinsAsync( + 1, + 10, + CoinTransactionType.ReviewReward, + 1, + null, + "Informative review reward"); + } + + private static async Task SeedPendingReviewAsync(AppDbContext db) + { + db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(new Lecture + { + Id = 1, + CourseId = 1, + Title = "Lecture", + StartsAt = DateTime.UtcNow.AddDays(-1), + EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2), + IsOpen = true, + MaxEnrollments = 30 + }); + db.Reviews.Add(new Review + { + Id = 1, + LectureId = 1, + UserId = 1, + Rating = ReviewRating.Like, + Text = "Useful review", + LlmStatus = ReviewLlmStatus.Pending + }); + await db.SaveChangesAsync(); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs new file mode 100644 index 0000000..9ddfe13 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewAnalysisWorkerTests.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using UniVerse.Api.BackgroundServices; +using UniVerse.Api.Options; +using UniVerse.Application.Interfaces; +using UniVerse.Infrastructure.Data; +using Xunit; + +namespace UniVerse.Api.Tests.Reviews; + +public class ReviewAnalysisWorkerTests +{ + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task Worker_DoesNotExceedConfiguredConcurrency(int maxConcurrentProcessing) + { + var queue = new ReviewAnalysisQueue(); + var analysisService = new RecordingLlmAnalysisService(); + await using var provider = CreateServiceProvider(analysisService); + var worker = new ReviewAnalysisWorker( + provider, + queue, + Microsoft.Extensions.Options.Options.Create( + new ReviewAnalysisOptions { MaxConcurrentProcessing = maxConcurrentProcessing }), + NullLogger.Instance); + + for (var reviewId = 1; reviewId <= 6; reviewId++) + await queue.EnqueueAsync(reviewId); + + analysisService.ExpectProcessed(6); + await worker.StartAsync(CancellationToken.None); + await analysisService.WaitForProcessedAsync(); + await worker.StopAsync(CancellationToken.None); + + Assert.True( + analysisService.MaxRunning <= maxConcurrentProcessing, + $"Expected at most {maxConcurrentProcessing} concurrent analyses, got {analysisService.MaxRunning}."); + } + + private static ServiceProvider CreateServiceProvider(ILlmAnalysisService analysisService) + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase($"ReviewAnalysisWorkerTests_{Guid.NewGuid()}")); + services.AddScoped(_ => analysisService); + return services.BuildServiceProvider(); + } + + private sealed class RecordingLlmAnalysisService : ILlmAnalysisService + { + private readonly TaskCompletionSource _processedAll = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _expectedCount; + private int _processedCount; + private int _running; + private int _maxRunning; + + public int MaxRunning => _maxRunning; + + public void ExpectProcessed(int expectedCount) + { + Volatile.Write(ref _expectedCount, expectedCount); + } + + public async Task AnalyzeReviewAsync(int reviewId) + { + var running = Interlocked.Increment(ref _running); + UpdateMaxRunning(running); + + await Task.Delay(50); + + Interlocked.Decrement(ref _running); + if (Interlocked.Increment(ref _processedCount) >= Volatile.Read(ref _expectedCount)) + _processedAll.TrySetResult(); + } + + public async Task WaitForProcessedAsync() + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var registration = timeout.Token.Register(() => _processedAll.TrySetCanceled(timeout.Token)); + await _processedAll.Task; + } + + private void UpdateMaxRunning(int running) + { + while (true) + { + var current = Volatile.Read(ref _maxRunning); + if (running <= current) return; + if (Interlocked.CompareExchange(ref _maxRunning, running, current) == current) return; + } + } + } +} diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs index 5a1a76a..0e1b517 100644 --- a/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs @@ -97,6 +97,42 @@ public class ReviewPromptServiceTests Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content); } + [Fact] + public async Task AnalyzeReviewAsync_ParsesSnakeCaseJsonFromFencedResponse() + { + var handler = new CapturingHandler(""" + ```json + {"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true} + ``` + """); + var http = new HttpClient(handler) + { + BaseAddress = new Uri("https://llm.test/") + }; + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Llm:Model"] = "test-model", + ["Llm:ApiKey"] = "test-key" + }) + .Build(); + var promptService = Substitute.For(); + promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null)); + var client = new LlmClient(http, config, promptService, NullLogger.Instance); + + var result = await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra"); + + Assert.Equal(0.82, result.QualityScore); + Assert.Equal("Положительный", result.Sentiment); + Assert.Equal(["lecture structure", "practical examples"], result.Tags); + Assert.True(result.IsInformative); + Assert.Equal(""" + ```json + {"quality_score":0.82,"sentiment":"Положительный","tags":["lecture structure","practical examples"],"is_informative":true} + ``` + """, result.RawOutput); + } + private static AppDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() @@ -107,6 +143,14 @@ public class ReviewPromptServiceTests private sealed class CapturingHandler : HttpMessageHandler { + private readonly string _analysisContent; + + public CapturingHandler(string? analysisContent = null) + { + _analysisContent = analysisContent ?? + "{\"quality_score\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"is_informative\":true}"; + } + public string? RequestBody { get; private set; } protected override async Task SendAsync( @@ -117,17 +161,19 @@ public class ReviewPromptServiceTests ? null : await request.Content.ReadAsStringAsync(cancellationToken); - const string responsePayload = """ + var responsePayload = JsonSerializer.Serialize(new + { + choices = new[] { - "choices": [ + new { - "message": { - "content": "{\"qualityScore\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"isInformative\":true}" - } + message = new + { + content = _analysisContent + } } - ] } - """; + }); return new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs new file mode 100644 index 0000000..1e1e633 --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewServiceTests.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Reviews; + +public class ReviewServiceTests +{ + [Fact] + public async Task CreateAsync_EnqueuesReviewAnalysis() + { + await using var db = CreateDbContext(); + var queue = Substitute.For(); + var service = CreateService(db, queue); + await SeedLectureAsync(db); + + var result = await service.CreateAsync(1, new CreateReviewRequest(1, ReviewRating.Like, "Great lecture")); + + await queue.Received(1).EnqueueAsync(result.Id, Arg.Any()); + } + + [Fact] + public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview() + { + await using var db = CreateDbContext(); + var queue = Substitute.For(); + var service = CreateService(db, queue); + await SeedAnalyzedReviewAsync(db); + + await service.UpdateAsync(1, 1, new UpdateReviewRequest(ReviewRating.Neutral, "Updated text")); + + var review = await db.Reviews.SingleAsync(r => r.Id == 1); + Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus); + Assert.Null(review.Sentiment); + Assert.Null(review.QualityScore); + Assert.Null(review.IsInformative); + Assert.Null(review.LlmTags); + Assert.Null(review.LlmRawOutput); + await queue.Received(1).EnqueueAsync(1, Arg.Any()); + } + + [Fact] + public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview() + { + await using var db = CreateDbContext(); + var queue = Substitute.For(); + var service = CreateService(db, queue); + await SeedAnalyzedReviewAsync(db); + + await service.ReanalyzeAsync(1); + + var review = await db.Reviews.SingleAsync(r => r.Id == 1); + Assert.Equal(ReviewLlmStatus.Pending, review.LlmStatus); + Assert.Null(review.Sentiment); + Assert.Null(review.QualityScore); + Assert.Null(review.IsInformative); + Assert.Null(review.LlmTags); + Assert.Null(review.LlmRawOutput); + await queue.Received(1).EnqueueAsync(1, Arg.Any()); + } + + private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue) + { + var gamification = Substitute.For(); + gamification.CheckAndAwardAchievementsAsync(Arg.Any()).Returns(Task.CompletedTask); + return new ReviewService(db, gamification, queue); + } + + private static async Task SeedLectureAsync(AppDbContext db) + { + db.Users.Add(new User { Id = 1, Email = "student@test.local", DisplayName = "Student" }); + db.Courses.Add(new Course { Id = 1, Name = "Course" }); + db.Lectures.Add(new Lecture + { + Id = 1, + CourseId = 1, + Title = "Lecture", + StartsAt = DateTime.UtcNow.AddDays(-1), + EndsAt = DateTime.UtcNow.AddDays(-1).AddHours(2), + IsOpen = true, + MaxEnrollments = 30 + }); + await db.SaveChangesAsync(); + } + + private static async Task SeedAnalyzedReviewAsync(AppDbContext db) + { + await SeedLectureAsync(db); + db.Reviews.Add(new Review + { + Id = 1, + LectureId = 1, + UserId = 1, + Rating = ReviewRating.Like, + Text = "Original text", + LlmStatus = ReviewLlmStatus.Analyzed, + Sentiment = ReviewSentiment.Positive, + QualityScore = 0.9, + IsInformative = true, + LlmTags = ["clear"], + LlmRawOutput = "{\"quality_score\":0.9}" + }); + await db.SaveChangesAsync(); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } +} diff --git a/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs b/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs deleted file mode 100644 index ef5b733..0000000 --- a/backend/UniVerse.Api/BackgroundServices/LlmProcessingBackgroundService.cs +++ /dev/null @@ -1,36 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using UniVerse.Application.Interfaces; - -namespace UniVerse.Api.BackgroundServices; - -public class LlmProcessingBackgroundService : BackgroundService -{ - private readonly IServiceProvider _services; - private readonly ILogger _logger; - - public LlmProcessingBackgroundService(IServiceProvider services, ILogger logger) - { - _services = services; _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("LLM Processing Background Service started"); - while (!stoppingToken.IsCancellationRequested) - { - try - { - using var scope = _services.CreateScope(); - var llmService = scope.ServiceProvider.GetRequiredService(); - await llmService.ProcessPendingReviewsAsync(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in LLM processing background service"); - } - await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken); - } - } -} diff --git a/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs new file mode 100644 index 0000000..8301f35 --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisQueue.cs @@ -0,0 +1,21 @@ +using System.Threading.Channels; +using UniVerse.Application.Interfaces; + +namespace UniVerse.Api.BackgroundServices; + +public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue +{ + private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false + }); + + public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default) + { + await _channel.Writer.WriteAsync(reviewId, cancellationToken); + } + + public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} diff --git a/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs new file mode 100644 index 0000000..f9851bc --- /dev/null +++ b/backend/UniVerse.Api/BackgroundServices/ReviewAnalysisWorker.cs @@ -0,0 +1,96 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using UniVerse.Api.Options; +using UniVerse.Application.Interfaces; +using UniVerse.Domain.Enums; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Api.BackgroundServices; + +public sealed class ReviewAnalysisWorker : BackgroundService +{ + private readonly IServiceProvider _services; + private readonly ReviewAnalysisQueue _queue; + private readonly ReviewAnalysisOptions _options; + private readonly ILogger _logger; + + public ReviewAnalysisWorker( + IServiceProvider services, + ReviewAnalysisQueue queue, + IOptions options, + ILogger logger) + { + _services = services; + _queue = queue; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing); + _logger.LogInformation( + "Review analysis worker started with max concurrency {MaxConcurrency}", + maxConcurrency); + + await EnqueueExistingPendingReviewsAsync(stoppingToken); + + var workers = Enumerable.Range(1, maxConcurrency) + .Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken)) + .ToArray(); + + try + { + await Task.WhenAll(workers); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogInformation("Review analysis worker stopped"); + } + } + + private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var pendingReviewIds = await db.Reviews + .Where(r => r.LlmStatus == ReviewLlmStatus.Pending) + .OrderBy(r => r.CreatedAt) + .Select(r => r.Id) + .ToListAsync(cancellationToken); + + foreach (var reviewId in pendingReviewIds) + await _queue.EnqueueAsync(reviewId, cancellationToken); + + if (pendingReviewIds.Count > 0) + _logger.LogInformation( + "Queued {ReviewCount} pending reviews for immediate analysis", + pendingReviewIds.Count); + } + + private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken) + { + await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken)) + { + try + { + using var scope = _services.CreateScope(); + var llmService = scope.ServiceProvider.GetRequiredService(); + await llmService.AnalyzeReviewAsync(reviewId); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Review analysis worker {WorkerNumber} failed to process review {ReviewId}", + workerNumber, + reviewId); + } + } + } +} diff --git a/backend/UniVerse.Api/Controllers/ReviewsController.cs b/backend/UniVerse.Api/Controllers/ReviewsController.cs index b01aef7..90790c3 100644 --- a/backend/UniVerse.Api/Controllers/ReviewsController.cs +++ b/backend/UniVerse.Api/Controllers/ReviewsController.cs @@ -28,7 +28,7 @@ public class ReviewsController : ControllerBase /// Создать отзыв к лекции. /// - /// Только Student. После создания отзыв помещается в очередь LLM-анализа + /// Только Student. После создания отзыв отправляется на LLM-анализ /// (статус `Pending`). LLM оценивает содержательность и начисляет монеты /// скрытно от пользователя. /// @@ -50,7 +50,7 @@ public class ReviewsController : ControllerBase /// Получить список всех отзывов. /// Только Admin. Возвращает все отзывы независимо от LLM-статуса. - /// Параметры пагинации. + /// Параметры фильтрации и пагинации. /// Список всех отзывов (пагинированный). /// Требуется аутентификация. /// Требуется роль Admin. @@ -59,8 +59,8 @@ public class ReviewsController : ControllerBase [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task List([FromQuery] PaginationRequest pagination) => - Ok(await _reviews.GetAllAsync(pagination)); + public async Task List([FromQuery] ReviewFilterRequest filter) => + Ok(await _reviews.GetAllAsync(filter)); /// Получить текущий промпт LLM-анализа отзывов. /// Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт. @@ -143,24 +143,10 @@ public class ReviewsController : ControllerBase return NoContent(); } - /// Получить список отзывов, ожидающих LLM-анализа. - /// Только Admin. Используется для мониторинга очереди обработки. - /// Параметры пагинации. - /// Список отзывов со статусом Pending (пагинированный). - /// Требуется аутентификация. - /// Требуется роль Admin. - [Authorize(Roles = "Admin")] - [HttpGet("pending")] - [ProducesResponseType(typeof(PagedResult), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task Pending([FromQuery] PaginationRequest pagination) => - Ok(await _reviews.GetPendingAsync(pagination)); - /// Запустить повторный LLM-анализ отзыва. /// - /// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его - /// в очередь на повторную обработку фоновым сервисом. + /// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его + /// на повторную обработку. /// /// ID отзыва. /// Повторный анализ запланирован. diff --git a/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs b/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs new file mode 100644 index 0000000..28704d3 --- /dev/null +++ b/backend/UniVerse.Api/Options/ReviewAnalysisOptions.cs @@ -0,0 +1,8 @@ +namespace UniVerse.Api.Options; + +public class ReviewAnalysisOptions +{ + public const string SectionName = "Llm:ReviewAnalysis"; + + public int MaxConcurrentProcessing { get; set; } = 1; +} diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 8daf54c..8475d02 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -9,6 +9,7 @@ using Serilog; using UniVerse.Api.BackgroundServices; using UniVerse.Api.Filters; using UniVerse.Api.Middleware; +using UniVerse.Api.Options; using UniVerse.Application.Interfaces; using UniVerse.Infrastructure.Services; using UniVerse.Infrastructure.Data; @@ -97,8 +98,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddTransient(); builder.Services.Configure(builder.Configuration.GetSection("Email:Smtp")); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName)) + .Validate(options => options.MaxConcurrentProcessing >= 1, + "Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.") + .ValidateOnStart(); builder.Services.AddQuartz(); if (!isOpenApiGeneration) @@ -132,7 +140,7 @@ builder.Services.AddHttpClient(client => // --- Background Services --- if (!isOpenApiGeneration) { - builder.Services.AddHostedService(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); } diff --git a/backend/UniVerse.Api/appsettings.json b/backend/UniVerse.Api/appsettings.json index 85ca651..ede48ed 100644 --- a/backend/UniVerse.Api/appsettings.json +++ b/backend/UniVerse.Api/appsettings.json @@ -16,7 +16,10 @@ "Llm": { "BaseUrl": "https://api.openai.com/v1/", "ApiKey": "", - "Model": "gpt-4o-mini" + "Model": "gpt-4o-mini", + "ReviewAnalysis": { + "MaxConcurrentProcessing": 1 + } }, "ModeusApi": { "BaseUrl": "https://schedule.rdcenter.ru", diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index b6c2186..4f2b60f 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -2445,7 +2445,7 @@ "Reviews" ], "summary": "Создать отзыв к лекции.", - "description": "Только Student. После создания отзыв помещается в очередь LLM-анализа\n(статус `Pending`). LLM оценивает содержательность и начисляет монеты\nскрытно от пользователя.\n\n**Required roles:** Student", + "description": "Только Student. После создания отзыв отправляется на LLM-анализ\n(статус `Pending`). LLM оценивает содержательность и начисляет монеты\nскрытно от пользователя.\n\n**Required roles:** Student", "requestBody": { "description": "ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.", "content": { @@ -2531,6 +2531,13 @@ "summary": "Получить список всех отзывов.", "description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin", "parameters": [ + { + "name": "LlmStatus", + "in": "query", + "schema": { + "$ref": "#/components/schemas/ReviewLlmStatus" + } + }, { "name": "Page", "in": "query", @@ -2920,77 +2927,13 @@ ] } }, - "/api/v1/reviews/pending": { - "get": { - "tags": [ - "Reviews" - ], - "summary": "Получить список отзывов, ожидающих LLM-анализа.", - "description": "Только Admin. Используется для мониторинга очереди обработки.\n\n**Required roles:** Admin", - "parameters": [ - { - "name": "Page", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - }, - { - "name": "PageSize", - "in": "query", - "schema": { - "type": "integer", - "format": "int32" - } - } - ], - "responses": { - "200": { - "description": "Список отзывов со статусом Pending (пагинированный).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReviewDtoPagedResult" - } - } - } - }, - "401": { - "description": "Требуется аутентификация.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "403": { - "description": "Требуется роль Admin.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - }, - "security": [ - { - "Bearer": [ ] - } - ] - } - }, "/api/v1/reviews/{id}/reanalyze": { "post": { "tags": [ "Reviews" ], "summary": "Запустить повторный LLM-анализ отзыва.", - "description": "Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его\nв очередь на повторную обработку фоновым сервисом.\n\n**Required roles:** Admin", + "description": "Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его\nна повторную обработку.\n\n**Required roles:** Admin", "parameters": [ { "name": "id", @@ -5582,6 +5525,10 @@ }, "nullable": true }, + "llmRawOutput": { + "type": "string", + "nullable": true + }, "createdAt": { "type": "string", "format": "date-time" diff --git a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs index 6ddc65a..004e282 100644 --- a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs +++ b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs @@ -15,6 +15,7 @@ public record ReviewDto( double? QualityScore, bool? IsInformative, string[]? LlmTags, + string? LlmRawOutput, DateTime CreatedAt ); @@ -22,6 +23,12 @@ public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Te public record UpdateReviewRequest(ReviewRating Rating, string? Text); +public record ReviewFilterRequest( + ReviewLlmStatus? LlmStatus, + int Page = 1, + int PageSize = 20 +); + public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt); public record UpdateReviewPromptRequest(string Prompt); diff --git a/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs b/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs index 7f5af0a..47f1fcc 100644 --- a/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs +++ b/backend/UniVerse.Application/Interfaces/ILlmAnalysisService.cs @@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces; public interface ILlmAnalysisService { Task AnalyzeReviewAsync(int reviewId); - Task ProcessPendingReviewsAsync(); } diff --git a/backend/UniVerse.Application/Interfaces/ILlmClient.cs b/backend/UniVerse.Application/Interfaces/ILlmClient.cs index dccefda..88d8a79 100644 --- a/backend/UniVerse.Application/Interfaces/ILlmClient.cs +++ b/backend/UniVerse.Application/Interfaces/ILlmClient.cs @@ -4,7 +4,8 @@ public record LlmReviewAnalysis( double QualityScore, string Sentiment, string[] Tags, - bool IsInformative + bool IsInformative, + string RawOutput ); public interface ILlmClient diff --git a/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs b/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs new file mode 100644 index 0000000..6d993ca --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/IReviewAnalysisQueue.cs @@ -0,0 +1,6 @@ +namespace UniVerse.Application.Interfaces; + +public interface IReviewAnalysisQueue +{ + Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default); +} diff --git a/backend/UniVerse.Application/Interfaces/IReviewService.cs b/backend/UniVerse.Application/Interfaces/IReviewService.cs index 003b16d..3399edc 100644 --- a/backend/UniVerse.Application/Interfaces/IReviewService.cs +++ b/backend/UniVerse.Application/Interfaces/IReviewService.cs @@ -11,7 +11,6 @@ public interface IReviewService Task DeleteAsync(int id, int userId, bool isAdmin = false); Task> GetByLectureAsync(int lectureId, PaginationRequest pagination); Task> GetByUserAsync(int userId, PaginationRequest pagination); - Task> GetAllAsync(PaginationRequest pagination); - Task> GetPendingAsync(PaginationRequest pagination); + Task> GetAllAsync(ReviewFilterRequest filter); Task ReanalyzeAsync(int id); } diff --git a/backend/UniVerse.Application/Mappings/MappingExtensions.cs b/backend/UniVerse.Application/Mappings/MappingExtensions.cs index 663b006..b41574c 100644 --- a/backend/UniVerse.Application/Mappings/MappingExtensions.cs +++ b/backend/UniVerse.Application/Mappings/MappingExtensions.cs @@ -84,7 +84,7 @@ public static class MappingExtensions review.UserId, review.User?.DisplayName, review.Rating, review.Text, review.LlmStatus, review.Sentiment, review.QualityScore, review.IsInformative, - review.LlmTags, review.CreatedAt + review.LlmTags, review.LlmRawOutput, review.CreatedAt ); // --- Achievement --- diff --git a/backend/UniVerse.Domain/Entities/Review.cs b/backend/UniVerse.Domain/Entities/Review.cs index 9883dcd..956c999 100644 --- a/backend/UniVerse.Domain/Entities/Review.cs +++ b/backend/UniVerse.Domain/Entities/Review.cs @@ -14,6 +14,7 @@ public class Review public double? QualityScore { get; set; } public bool? IsInformative { get; set; } public string[]? LlmTags { get; set; } + public string? LlmRawOutput { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs index d3d7c8e..f53ccec 100644 --- a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs +++ b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewConfiguration.cs @@ -21,6 +21,7 @@ public class ReviewConfiguration : IEntityTypeConfiguration builder.Property(r => r.QualityScore).HasColumnName("quality_score"); builder.Property(r => r.IsInformative).HasColumnName("is_informative"); builder.Property(r => r.LlmTags).HasColumnName("llm_tags"); + builder.Property(r => r.LlmRawOutput).HasColumnName("llm_raw_output"); builder.Property(r => r.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()"); builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()"); diff --git a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs index 6341fde..8a67f40 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs @@ -1,5 +1,6 @@ using System.Net.Http.Json; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using UniVerse.Application.Interfaces; @@ -49,11 +50,37 @@ public class LlmClient : ILlmClient var json = await response.Content.ReadFromJsonAsync(); var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!; - var analysis = JsonSerializer.Deserialize(content, + var analysisJson = NormalizeJsonContent(content); + var analysis = JsonSerializer.Deserialize(analysisJson, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; - return new LlmReviewAnalysis(analysis.QualityScore, analysis.Sentiment, analysis.Tags, analysis.IsInformative); + return new LlmReviewAnalysis( + Math.Clamp(analysis.QualityScore, 0, 1), + analysis.Sentiment ?? "", + analysis.Tags ?? [], + analysis.IsInformative, + content); } - private record LlmRawResponse(double QualityScore, string Sentiment, string[] Tags, bool IsInformative); + private static string NormalizeJsonContent(string content) + { + var trimmed = content.Trim(); + if (!trimmed.StartsWith("```", StringComparison.Ordinal)) + return trimmed; + + var firstNewLine = trimmed.IndexOf('\n'); + if (firstNewLine < 0) + return trimmed; + + var lastFence = trimmed.LastIndexOf("```", StringComparison.Ordinal); + return lastFence > firstNewLine + ? trimmed[(firstNewLine + 1)..lastFence].Trim() + : trimmed[(firstNewLine + 1)..].Trim(); + } + + private record LlmRawResponse( + [property: JsonPropertyName("quality_score")] double QualityScore, + string? Sentiment, + string[]? Tags, + [property: JsonPropertyName("is_informative")] bool IsInformative); } diff --git a/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs b/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs new file mode 100644 index 0000000..3383226 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260522120000_ReviewLlmRawOutput.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class ReviewLlmRawOutput : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "llm_raw_output", + table: "reviews", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "llm_raw_output", + table: "reviews"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 42ae979..96fd50d 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -521,6 +521,10 @@ namespace UniVerse.Infrastructure.Migrations .HasDefaultValue(0) .HasColumnName("llm_status"); + b.Property("LlmRawOutput") + .HasColumnType("text") + .HasColumnName("llm_raw_output"); + b.PrimitiveCollection("LlmTags") .HasColumnType("text[]") .HasColumnName("llm_tags"); diff --git a/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs index 898aeae..f2eb71f 100644 --- a/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs +++ b/backend/UniVerse.Infrastructure/Services/LlmAnalysisService.cs @@ -31,10 +31,10 @@ public class LlmAnalysisService : ILlmAnalysisService var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context); review.QualityScore = result.QualityScore; - review.Sentiment = Enum.TryParse(result.Sentiment, true, out var s) - ? s : ReviewSentiment.Neutral; + review.Sentiment = ParseSentiment(result.Sentiment); review.LlmTags = result.Tags; review.IsInformative = result.IsInformative; + review.LlmRawOutput = result.RawOutput; review.LlmStatus = ReviewLlmStatus.Analyzed; review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); @@ -53,14 +53,16 @@ public class LlmAnalysisService : ILlmAnalysisService } } - public async Task ProcessPendingReviewsAsync() + private static ReviewSentiment ParseSentiment(string value) { - var pending = await _db.Reviews - .Where(r => r.LlmStatus == ReviewLlmStatus.Pending) - .OrderBy(r => r.CreatedAt).Take(10) - .Select(r => r.Id).ToListAsync(); - - foreach (var id in pending) - await AnalyzeReviewAsync(id); + var normalized = value.Trim().ToLowerInvariant(); + return normalized switch + { + "positive" or "положительный" or "положительная" or "позитивный" or "позитивная" => ReviewSentiment.Positive, + "negative" or "отрицательный" or "отрицательная" or "негативный" or "негативная" => ReviewSentiment.Negative, + "neutral" or "нейтральный" or "нейтральная" => ReviewSentiment.Neutral, + _ when Enum.TryParse(value, true, out var sentiment) => sentiment, + _ => ReviewSentiment.Neutral + }; } } diff --git a/backend/UniVerse.Infrastructure/Services/ReviewService.cs b/backend/UniVerse.Infrastructure/Services/ReviewService.cs index bc1205a..c850ef1 100644 --- a/backend/UniVerse.Infrastructure/Services/ReviewService.cs +++ b/backend/UniVerse.Infrastructure/Services/ReviewService.cs @@ -14,11 +14,16 @@ public class ReviewService : IReviewService { private readonly AppDbContext _db; private readonly IGamificationService _gamification; + private readonly IReviewAnalysisQueue _reviewAnalysisQueue; - public ReviewService(AppDbContext db, IGamificationService gamification) + public ReviewService( + AppDbContext db, + IGamificationService gamification, + IReviewAnalysisQueue reviewAnalysisQueue) { _db = db; _gamification = gamification; + _reviewAnalysisQueue = reviewAnalysisQueue; } private IQueryable BaseQuery() => _db.Reviews @@ -38,6 +43,7 @@ public class ReviewService : IReviewService _db.Reviews.Add(review); await _db.SaveChangesAsync(); await _gamification.CheckAndAwardAchievementsAsync(userId); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); var full = await BaseQuery().FirstAsync(r => r.Id == review.Id); return full.ToDto(); } @@ -54,9 +60,10 @@ public class ReviewService : IReviewService var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); if (review.UserId != userId) throw new ForbiddenException(); review.Rating = req.Rating; review.Text = req.Text; - review.LlmStatus = ReviewLlmStatus.Pending; + ResetLlmAnalysis(review); review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); return await GetByIdAsync(id); } @@ -86,29 +93,34 @@ public class ReviewService : IReviewService return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); } - public async Task> GetAllAsync(PaginationRequest pagination) + public async Task> GetAllAsync(ReviewFilterRequest filter) { var query = BaseQuery(); + if (filter.LlmStatus.HasValue) + query = query.Where(r => r.LlmStatus == filter.LlmStatus.Value); + var total = await query.CountAsync(); var items = await query.OrderByDescending(r => r.CreatedAt) - .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); - return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); - } - - public async Task> GetPendingAsync(PaginationRequest pagination) - { - var query = BaseQuery().Where(r => r.LlmStatus == ReviewLlmStatus.Pending); - var total = await query.CountAsync(); - var items = await query.OrderBy(r => r.CreatedAt) - .Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync(); - return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize); + .Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync(); + return PagedResult.Create(items.Select(r => r.ToDto()).ToList(), total, filter.Page, filter.PageSize); } public async Task ReanalyzeAsync(int id) { var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id); - review.LlmStatus = ReviewLlmStatus.Pending; + ResetLlmAnalysis(review); review.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); + await _reviewAnalysisQueue.EnqueueAsync(review.Id); + } + + private static void ResetLlmAnalysis(Review review) + { + review.LlmStatus = ReviewLlmStatus.Pending; + review.Sentiment = null; + review.QualityScore = null; + review.IsInformative = null; + review.LlmTags = null; + review.LlmRawOutput = null; } } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 972ad2c..6ef26b3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -172,13 +172,6 @@ async function listReviewsPage(query: ReviewQuery = {}) { return normalizePagedResult(payload, query) } -async function listPendingReviewsPage(query: ReviewQuery = {}) { - const payload = await apiRequest | ReviewDto[]>('/reviews/pending', { - query: query as Record, - }) - return normalizePagedResult(payload, query) -} - export const reviewsApi = { create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) => apiRequest('/reviews', { @@ -195,10 +188,6 @@ export const reviewsApi = { async list(query: ReviewQuery = { PageSize: 100 }) { return (await listReviewsPage(query)).items }, - pendingPage: listPendingReviewsPage, - async pending(query: ReviewQuery = { PageSize: 100 }) { - return (await listPendingReviewsPage(query)).items - }, reanalyze: (id: string | number) => apiRequest(`/reviews/${id}/reanalyze`, { method: 'POST' }), } diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index c5b6c2d..534049f 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -93,7 +93,8 @@ export function mapApiLecture(lecture: LectureDto): Lecture { } export function mapApiReview(review: ReviewDto): Review { - const sentiment = review.sentiment === 'Negative' ? 'negative' : review.sentiment === 'Neutral' ? 'neutral' : 'positive' + const sentiment = + review.sentiment === 'Positive' ? 'positive' : review.sentiment === 'Negative' ? 'negative' : 'neutral' const status = review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending' diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 6126947..afebc53 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -116,6 +116,7 @@ export interface CreateLectureRequest { } export interface ReviewQuery { + LlmStatus?: ApiReviewLlmStatus Page?: number PageSize?: number } @@ -129,10 +130,11 @@ export interface ReviewDto { rating: ApiReviewRating text?: string | null llmStatus: ApiReviewLlmStatus - sentiment: ApiReviewSentiment + sentiment?: ApiReviewSentiment | null qualityScore?: number | null isInformative?: boolean | null llmTags?: string[] | null + llmRawOutput?: string | null createdAt: string } diff --git a/frontend/src/components/layout/AppBottomNav.vue b/frontend/src/components/layout/AppBottomNav.vue index e311c47..bbaae61 100644 --- a/frontend/src/components/layout/AppBottomNav.vue +++ b/frontend/src/components/layout/AppBottomNav.vue @@ -19,7 +19,7 @@ const navItems = computed(() => { { label: 'Дашборд', icon: 'shield', to: '/admin' }, { label: 'Юзеры', icon: 'users', to: '/admin/users' }, { label: 'Лекции', icon: 'books', to: '/admin/lectures' }, - { label: 'ИИ', icon: 'robot', to: '/admin/llm-queue' }, + { label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' }, ] return [ { label: 'Главная', icon: 'home', to: '/' }, diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 60636ba..faf05b2 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -23,7 +23,6 @@ const navItems: NavItem[] = [ { label: 'Дашборд', icon: 'shield', to: '/admin', roles: ['admin'] }, { label: 'Пользователи', icon: 'users', to: '/admin/users', roles: ['admin'] }, { label: 'Лекции', icon: 'books', to: '/admin/lectures', roles: ['admin'] }, - { label: 'ИИ очередь', icon: 'robot', to: '/admin/llm-queue', roles: ['admin'] }, { label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews', roles: ['admin'] }, ] diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index ea9b5b5..535698a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -30,7 +30,7 @@ const router = createRouter({ { path: '/admin', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue'), meta: { role: 'admin' } }, { path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } }, { path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } }, - { path: '/admin/llm-queue', name: 'admin-llm', component: () => import('@/views/admin/AdminLLMQueueView.vue'), meta: { role: 'admin' } }, + { path: '/admin/llm-queue', redirect: '/admin/reviews' }, { path: '/admin/reviews', name: 'admin-reviews', component: () => import('@/views/admin/AdminReviewsView.vue'), meta: { role: 'admin' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, diff --git a/frontend/src/views/admin/AdminDashboardView.vue b/frontend/src/views/admin/AdminDashboardView.vue index 3f167cd..24ba4b3 100644 --- a/frontend/src/views/admin/AdminDashboardView.vue +++ b/frontend/src/views/admin/AdminDashboardView.vue @@ -2,7 +2,6 @@ import { computed, onMounted, ref } from 'vue' import GlassCard from '@/components/ui/GlassCard.vue' import StatsWidget from '@/components/ui/StatsWidget.vue' -import ProgressBar from '@/components/ui/ProgressBar.vue' import StatusBadge from '@/components/ui/StatusBadge.vue' import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api' import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types' @@ -25,7 +24,7 @@ onMounted(async () => { const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([ usersApi.list({ PageSize: 100 }), lecturesApi.list({ PageSize: 100 }), - reviewsApi.pendingPage({ Page: 1, PageSize: 1 }), + reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }), syncApi.status(), ]) if (usersResult.status === 'fulfilled') users.value = usersResult.value @@ -45,7 +44,7 @@ onMounted(async () => { { Ошибка: {{ syncStatus.lastResult.error }} - -
Очередь LLM-анализа
-
В очереди: {{ pendingReviewsCount }} отзывов
- -
Следующая проверка через 12 минут
-
@@ -147,14 +140,4 @@ onMounted(async () => { color: var(--color-error); margin-top: 8px; } -.queue-meta { - font-size: 12px; - color: var(--color-text-secondary); - margin-bottom: 8px; -} -.queue-status { - font-size: 12px; - color: var(--color-text-secondary); - margin-top: 6px; -} diff --git a/frontend/src/views/admin/AdminLLMQueueView.vue b/frontend/src/views/admin/AdminLLMQueueView.vue deleted file mode 100644 index 0acfeca..0000000 --- a/frontend/src/views/admin/AdminLLMQueueView.vue +++ /dev/null @@ -1,170 +0,0 @@ - - - - - diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index c241f72..ef1310b 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -5,7 +5,7 @@ import DataTable from '@/components/ui/DataTable.vue' import StatusBadge from '@/components/ui/StatusBadge.vue' import EmptyState from '@/components/ui/EmptyState.vue' import { reviewsApi } from '@/api' -import type { ReviewDto } from '@/api/types' +import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types' const columns = [ { key: 'id', label: 'ID' }, @@ -14,10 +14,7 @@ const columns = [ { key: 'rating', label: 'Оценка', align: 'center' }, { key: 'text', label: 'Текст' }, { key: 'date', label: 'Дата' }, - { key: 'status', label: 'LLM-статус', align: 'center' }, - { key: 'sentiment', label: 'Sentiment', align: 'center' }, - { key: 'quality', label: 'Quality', align: 'center' }, - { key: 'informativeTags', label: 'Informative / tags' }, + { key: 'analysis', label: 'Результат нейронки' }, { key: 'actions', label: 'Действия', align: 'right' }, ] @@ -33,12 +30,32 @@ const ratingLabel: Record = { Dislike: '👎 Dislike', } +const statusFilters: Array<{ label: string; value: ApiReviewLlmStatus | 'All' }> = [ + { label: 'Все', value: 'All' }, + { label: 'На проверке', value: 'Pending' }, + { label: 'Проверены', value: 'Analyzed' }, + { label: 'Отклонены', value: 'Rejected' }, +] + +const sentimentLabel: Record = { + Positive: 'Позитивный', + Neutral: 'Нейтральный', + Negative: 'Негативный', +} + +const sentimentClass: Record = { + Positive: 'badge-green', + Neutral: 'badge-orange', + Negative: 'badge-red', +} + const reviews = ref([]) const loading = ref(false) const page = ref(1) const pageSize = ref(20) const totalCount = ref(0) const totalPages = ref(0) +const statusFilter = ref('All') const reanalyzingId = ref(null) const error = ref('') const promptText = ref('') @@ -50,28 +67,36 @@ const promptError = ref('') const promptSuccess = ref('') const rows = computed(() => - reviews.value.map((review) => ({ - id: review.id, - lecture: review.lectureTitle || `#${review.lectureId}`, - student: review.userName || `#${review.userId}`, - rating: ratingLabel[review.rating] ?? review.rating, - text: review.text || '—', - date: new Date(review.createdAt).toLocaleString('ru-RU'), - status: statusMap[review.llmStatus] ?? review.llmStatus, - rawStatus: review.llmStatus, - sentiment: review.sentiment, - quality: review.qualityScore, - informative: review.isInformative, - tags: review.llmTags ?? [], - informativeTags: [ - review.isInformative === null || review.isInformative === undefined - ? '—' - : review.isInformative - ? 'Да' - : 'Нет', - ...(review.llmTags ?? []), - ].join(' / '), - })), + reviews.value.map((review) => { + const analysisReady = review.llmStatus === 'Analyzed' + const sentiment = analysisReady ? review.sentiment : null + const tags = analysisReady ? normalizeTags(review.llmTags) : [] + + return { + id: review.id, + lecture: review.lectureTitle || `#${review.lectureId}`, + student: review.userName || `#${review.userId}`, + rating: ratingLabel[review.rating] ?? review.rating, + text: review.text || '—', + date: new Date(review.createdAt).toLocaleString('ru-RU'), + status: statusMap[review.llmStatus] ?? review.llmStatus, + rawStatus: review.llmStatus, + analysis: review.llmStatus, + analysisReady, + analysisMessage: getAnalysisMessage(review), + sentiment, + sentimentLabel: sentiment ? sentimentLabel[sentiment] : '—', + sentimentClass: sentiment ? sentimentClass[sentiment] : 'badge-gray', + quality: analysisReady ? review.qualityScore : null, + qualityLabel: analysisReady ? formatQuality(review.qualityScore) : '—', + qualityClass: getQualityClass(analysisReady ? review.qualityScore : null), + informative: analysisReady ? review.isInformative : null, + informativeLabel: analysisReady ? formatInformative(review.isInformative) : '—', + informativeClass: getInformativeClass(analysisReady ? review.isInformative : null), + tags, + llmRawOutput: analysisReady ? formatRawOutput(review.llmRawOutput) : '', + } + }), ) const pageStart = computed(() => @@ -93,9 +118,44 @@ const canSavePrompt = computed( promptText.value !== savedPromptText.value, ) +function normalizeTags(tags: string[] | null | undefined) { + return Array.from(new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))) +} + function formatQuality(value: number | null | undefined) { if (value === null || value === undefined) return '—' - return Number(value).toFixed(2) + const numeric = Number(value) + if (!Number.isFinite(numeric)) return '—' + const score = Math.max(0, Math.min(1, numeric)) + return `${Math.round(score * 100)}%` +} + +function getQualityClass(value: number | null | undefined) { + if (value === null || value === undefined) return 'badge-gray' + if (!Number.isFinite(Number(value))) return 'badge-gray' + if (value >= 0.7) return 'badge-green' + if (value >= 0.4) return 'badge-orange' + return 'badge-red' +} + +function formatInformative(value: boolean | null | undefined) { + if (value === null || value === undefined) return '—' + return value ? 'Информативный' : 'Неинформативный' +} + +function getInformativeClass(value: boolean | null | undefined) { + if (value === null || value === undefined) return 'badge-gray' + return value ? 'badge-green' : 'badge-red' +} + +function getAnalysisMessage(review: ReviewDto) { + if (review.llmStatus === 'Pending') return 'Ожидает обработки' + if (review.llmStatus === 'Rejected') return 'Отзыв отклонён нейронкой' + return 'Анализ завершён, данных нет' +} + +function formatRawOutput(value: string | null | undefined) { + return value?.trim() || '' } async function fetchPrompt() { @@ -144,6 +204,7 @@ async function fetchReviews() { const result = await reviewsApi.listPage({ Page: page.value, PageSize: pageSize.value, + ...(statusFilter.value === 'All' ? {} : { LlmStatus: statusFilter.value }), }) reviews.value = result.items totalCount.value = result.totalCount @@ -155,6 +216,13 @@ async function fetchReviews() { } } +async function selectStatusFilter(nextStatus: ApiReviewLlmStatus | 'All') { + if (statusFilter.value === nextStatus) return + statusFilter.value = nextStatus + page.value = 1 + await fetchReviews() +} + async function goToPage(nextPage: number) { if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return page.value = nextPage @@ -224,6 +292,21 @@ onMounted(() => { +
+
+ +
+
+ { - - - -