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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user