feat: изменил логику анализа отзывов
Backend CI / build-and-test (push) Failing after 14m19s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 12m5s
Frontend CI / build-and-check (push) Failing after 17m58s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 10m11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 11m3s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 14m58s

This commit is contained in:
2026-05-22 01:30:41 +03:00
parent 168d6af860
commit 8ac593d36f
36 changed files with 858 additions and 457 deletions
@@ -121,10 +121,10 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews GET [Admin]", "GET", "api/v1/reviews", "Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
yield return E("reviews/pending GET [Admin]", "GET", "api/v1/reviews/pending","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
// ── Tags — any auth ───────────────────────────────────────────────────
@@ -213,7 +213,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
var stub = Substitute.For<IReviewService>();
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<ReviewDto>.Create([reviewDto], 1, 1, 20);
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
@@ -222,7 +222,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
stub.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetByUserAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetPendingAsync(Arg.Any<PaginationRequest>()).Returns(pagedReviews);
stub.GetAllAsync(Arg.Any<ReviewFilterRequest>()).Returns(pagedReviews);
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
return stub;
}
@@ -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<ILlmClient>();
llm.AnalyzeReviewAsync(Arg.Any<string>(), Arg.Any<string>())
.Returns(new LlmReviewAnalysis(
0.76,
"Положительный",
["lecture structure", "practical examples"],
true,
"{\"quality_score\":0.76,\"sentiment\":\"Положительный\"}"));
var gamification = Substitute.For<IGamificationService>();
gamification.AwardCoinsAsync(
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<CoinTransactionType>(),
Arg.Any<int?>(),
Arg.Any<int?>(),
Arg.Any<string?>())
.Returns(Task.CompletedTask);
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
var service = new LlmAnalysisService(db, llm, gamification, NullLogger<LlmAnalysisService>.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<AppDbContext>()
.UseInMemoryDatabase($"LlmAnalysisServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -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<ReviewAnalysisWorker>.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<AppDbContext>(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;
}
}
}
}
@@ -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<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(ReviewPromptTemplate.Default, null));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.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<AppDbContext>()
@@ -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<HttpResponseMessage> 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)
{
@@ -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<IReviewAnalysisQueue>();
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<CancellationToken>());
}
[Fact]
public async Task UpdateAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
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<CancellationToken>());
}
[Fact]
public async Task ReanalyzeAsync_ResetsAnalysisAndEnqueuesReview()
{
await using var db = CreateDbContext();
var queue = Substitute.For<IReviewAnalysisQueue>();
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<CancellationToken>());
}
private static ReviewService CreateService(AppDbContext db, IReviewAnalysisQueue queue)
{
var gamification = Substitute.For<IGamificationService>();
gamification.CheckAndAwardAchievementsAsync(Arg.Any<int>()).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<AppDbContext>()
.UseInMemoryDatabase($"ReviewServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
}
@@ -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<LlmProcessingBackgroundService> _logger;
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> 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<ILlmAnalysisService>();
await llmService.ProcessPendingReviewsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in LLM processing background service");
}
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
}
}
}
@@ -0,0 +1,21 @@
using System.Threading.Channels;
using UniVerse.Application.Interfaces;
namespace UniVerse.Api.BackgroundServices;
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
{
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
{
SingleReader = false,
SingleWriter = false
});
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
}
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
_channel.Reader.ReadAllAsync(cancellationToken);
}
@@ -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<ReviewAnalysisWorker> _logger;
public ReviewAnalysisWorker(
IServiceProvider services,
ReviewAnalysisQueue queue,
IOptions<ReviewAnalysisOptions> options,
ILogger<ReviewAnalysisWorker> 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<AppDbContext>();
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<ILlmAnalysisService>();
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);
}
}
}
}
@@ -28,7 +28,7 @@ public class ReviewsController : ControllerBase
/// <summary>Создать отзыв к лекции.</summary>
/// <remarks>
/// Только Student. После создания отзыв помещается в очередь LLM-анализа
/// Только Student. После создания отзыв отправляется на LLM-анализ
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
/// скрытно от пользователя.
/// </remarks>
@@ -50,7 +50,7 @@ public class ReviewsController : ControllerBase
/// <summary>Получить список всех отзывов.</summary>
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
/// <param name="pagination">Параметры пагинации.</param>
/// <param name="filter">Параметры фильтрации и пагинации.</param>
/// <response code="200">Список всех отзывов (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
@@ -59,8 +59,8 @@ public class ReviewsController : ControllerBase
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetAllAsync(pagination));
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
Ok(await _reviews.GetAllAsync(filter));
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
@@ -143,24 +143,10 @@ public class ReviewsController : ControllerBase
return NoContent();
}
/// <summary>Получить список отзывов, ожидающих LLM-анализа.</summary>
/// <remarks>Только Admin. Используется для мониторинга очереди обработки.</remarks>
/// <param name="pagination">Параметры пагинации.</param>
/// <response code="200">Список отзывов со статусом Pending (пагинированный).</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("pending")]
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetPendingAsync(pagination));
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
/// <remarks>
/// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
/// в очередь на повторную обработку фоновым сервисом.
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
/// на повторную обработку.
/// </remarks>
/// <param name="id">ID отзыва.</param>
/// <response code="204">Повторный анализ запланирован.</response>
@@ -0,0 +1,8 @@
namespace UniVerse.Api.Options;
public class ReviewAnalysisOptions
{
public const string SectionName = "Llm:ReviewAnalysis";
public int MaxConcurrentProcessing { get; set; } = 1;
}
+9 -1
View File
@@ -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<IScheduleSyncService, ScheduleSyncService>();
builder.Services.AddScoped<INotificationService, NotificationService>();
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
builder.Services.AddSingleton<ReviewAnalysisQueue>();
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
builder.Services.AddTransient<NotificationJob>();
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
builder.Services.AddOptions<ReviewAnalysisOptions>()
.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<IModeusApiClient, ModeusApiClient>(client =>
// --- Background Services ---
if (!isOpenApiGeneration)
{
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
builder.Services.AddHostedService<ReviewAnalysisWorker>();
builder.Services.AddHostedService<AchievementCatalogHostedService>();
}
+4 -1
View File
@@ -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",
+13 -66
View File
@@ -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"
@@ -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);
@@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces;
public interface ILlmAnalysisService
{
Task AnalyzeReviewAsync(int reviewId);
Task ProcessPendingReviewsAsync();
}
@@ -4,7 +4,8 @@ public record LlmReviewAnalysis(
double QualityScore,
string Sentiment,
string[] Tags,
bool IsInformative
bool IsInformative,
string RawOutput
);
public interface ILlmClient
@@ -0,0 +1,6 @@
namespace UniVerse.Application.Interfaces;
public interface IReviewAnalysisQueue
{
Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default);
}
@@ -11,7 +11,6 @@ public interface IReviewService
Task DeleteAsync(int id, int userId, bool isAdmin = false);
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetAllAsync(PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
Task ReanalyzeAsync(int id);
}
@@ -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 ---
@@ -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;
@@ -21,6 +21,7 @@ public class ReviewConfiguration : IEntityTypeConfiguration<Review>
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()");
@@ -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<JsonElement>();
var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!;
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(content,
var analysisJson = NormalizeJsonContent(content);
var analysis = JsonSerializer.Deserialize<LlmRawResponse>(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);
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ReviewLlmRawOutput : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "llm_raw_output",
table: "reviews",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "llm_raw_output",
table: "reviews");
}
}
}
@@ -521,6 +521,10 @@ namespace UniVerse.Infrastructure.Migrations
.HasDefaultValue(0)
.HasColumnName("llm_status");
b.Property<string>("LlmRawOutput")
.HasColumnType("text")
.HasColumnName("llm_raw_output");
b.PrimitiveCollection<string[]>("LlmTags")
.HasColumnType("text[]")
.HasColumnName("llm_tags");
@@ -31,10 +31,10 @@ public class LlmAnalysisService : ILlmAnalysisService
var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context);
review.QualityScore = result.QualityScore;
review.Sentiment = Enum.TryParse<ReviewSentiment>(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<ReviewSentiment>(value, true, out var sentiment) => sentiment,
_ => ReviewSentiment.Neutral
};
}
}
@@ -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<Review> 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<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
}
public async Task<PagedResult<ReviewDto>> GetAllAsync(PaginationRequest pagination)
public async Task<PagedResult<ReviewDto>> 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<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
}
public async Task<PagedResult<ReviewDto>> 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<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
.Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync();
return PagedResult<ReviewDto>.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;
}
}