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!"}""");
|
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
|
||||||
|
|
||||||
// ── Reviews — Admin only ──────────────────────────────────────────────
|
// ── 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 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"],
|
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
|
||||||
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
|
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"]);
|
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
|
||||||
|
|
||||||
// ── Tags — any auth ───────────────────────────────────────────────────
|
// ── Tags — any auth ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
|
|||||||
var stub = Substitute.For<IReviewService>();
|
var stub = Substitute.For<IReviewService>();
|
||||||
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
|
var reviewDto = new ReviewDto(1, 1, "Lecture", 1, "User",
|
||||||
ReviewRating.Like, "Great!", ReviewLlmStatus.Pending,
|
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);
|
var pagedReviews = PagedResult<ReviewDto>.Create([reviewDto], 1, 1, 20);
|
||||||
|
|
||||||
stub.CreateAsync(Arg.Any<int>(), Arg.Any<CreateReviewRequest>()).Returns(reviewDto);
|
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.DeleteAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<bool>()).Returns(Task.CompletedTask);
|
||||||
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
stub.GetByLectureAsync(Arg.Any<int>(), Arg.Any<PaginationRequest>()).Returns(pagedReviews);
|
||||||
stub.GetByUserAsync(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);
|
stub.ReanalyzeAsync(Arg.Any<int>()).Returns(Task.CompletedTask);
|
||||||
return stub;
|
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);
|
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()
|
private static AppDbContext CreateDbContext()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<AppDbContext>()
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
@@ -107,6 +143,14 @@ public class ReviewPromptServiceTests
|
|||||||
|
|
||||||
private sealed class CapturingHandler : HttpMessageHandler
|
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; }
|
public string? RequestBody { get; private set; }
|
||||||
|
|
||||||
protected override async Task<HttpResponseMessage> SendAsync(
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
@@ -117,17 +161,19 @@ public class ReviewPromptServiceTests
|
|||||||
? null
|
? null
|
||||||
: await request.Content.ReadAsStringAsync(cancellationToken);
|
: await request.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
const string responsePayload = """
|
var responsePayload = JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
"choices": [
|
choices = new[]
|
||||||
{
|
{
|
||||||
"message": {
|
new
|
||||||
"content": "{\"qualityScore\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"isInformative\":true}"
|
{
|
||||||
|
message = new
|
||||||
|
{
|
||||||
|
content = _analysisContent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
""";
|
});
|
||||||
|
|
||||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
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>
|
/// <summary>Создать отзыв к лекции.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Только Student. После создания отзыв помещается в очередь LLM-анализа
|
/// Только Student. После создания отзыв отправляется на LLM-анализ
|
||||||
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
||||||
/// скрытно от пользователя.
|
/// скрытно от пользователя.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -50,7 +50,7 @@ public class ReviewsController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>Получить список всех отзывов.</summary>
|
/// <summary>Получить список всех отзывов.</summary>
|
||||||
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
|
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
|
||||||
/// <param name="pagination">Параметры пагинации.</param>
|
/// <param name="filter">Параметры фильтрации и пагинации.</param>
|
||||||
/// <response code="200">Список всех отзывов (пагинированный).</response>
|
/// <response code="200">Список всех отзывов (пагинированный).</response>
|
||||||
/// <response code="401">Требуется аутентификация.</response>
|
/// <response code="401">Требуется аутентификация.</response>
|
||||||
/// <response code="403">Требуется роль Admin.</response>
|
/// <response code="403">Требуется роль Admin.</response>
|
||||||
@@ -59,8 +59,8 @@ public class ReviewsController : ControllerBase
|
|||||||
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) =>
|
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
|
||||||
Ok(await _reviews.GetAllAsync(pagination));
|
Ok(await _reviews.GetAllAsync(filter));
|
||||||
|
|
||||||
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
|
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
|
||||||
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
|
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
|
||||||
@@ -143,24 +143,10 @@ public class ReviewsController : ControllerBase
|
|||||||
return NoContent();
|
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>
|
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
|
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
|
||||||
/// в очередь на повторную обработку фоновым сервисом.
|
/// на повторную обработку.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="id">ID отзыва.</param>
|
/// <param name="id">ID отзыва.</param>
|
||||||
/// <response code="204">Повторный анализ запланирован.</response>
|
/// <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,6 +9,7 @@ using Serilog;
|
|||||||
using UniVerse.Api.BackgroundServices;
|
using UniVerse.Api.BackgroundServices;
|
||||||
using UniVerse.Api.Filters;
|
using UniVerse.Api.Filters;
|
||||||
using UniVerse.Api.Middleware;
|
using UniVerse.Api.Middleware;
|
||||||
|
using UniVerse.Api.Options;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
using UniVerse.Infrastructure.Services;
|
using UniVerse.Infrastructure.Services;
|
||||||
using UniVerse.Infrastructure.Data;
|
using UniVerse.Infrastructure.Data;
|
||||||
@@ -97,8 +98,15 @@ builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
|||||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||||
|
builder.Services.AddSingleton<ReviewAnalysisQueue>();
|
||||||
|
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
|
||||||
builder.Services.AddTransient<NotificationJob>();
|
builder.Services.AddTransient<NotificationJob>();
|
||||||
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
|
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();
|
builder.Services.AddQuartz();
|
||||||
if (!isOpenApiGeneration)
|
if (!isOpenApiGeneration)
|
||||||
@@ -132,7 +140,7 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
|||||||
// --- Background Services ---
|
// --- Background Services ---
|
||||||
if (!isOpenApiGeneration)
|
if (!isOpenApiGeneration)
|
||||||
{
|
{
|
||||||
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
builder.Services.AddHostedService<ReviewAnalysisWorker>();
|
||||||
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
"Llm": {
|
"Llm": {
|
||||||
"BaseUrl": "https://api.openai.com/v1/",
|
"BaseUrl": "https://api.openai.com/v1/",
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
"Model": "gpt-4o-mini"
|
"Model": "gpt-4o-mini",
|
||||||
|
"ReviewAnalysis": {
|
||||||
|
"MaxConcurrentProcessing": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ModeusApi": {
|
"ModeusApi": {
|
||||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||||
|
|||||||
@@ -2445,7 +2445,7 @@
|
|||||||
"Reviews"
|
"Reviews"
|
||||||
],
|
],
|
||||||
"summary": "Создать отзыв к лекции.",
|
"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": {
|
"requestBody": {
|
||||||
"description": "ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.",
|
"description": "ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.",
|
||||||
"content": {
|
"content": {
|
||||||
@@ -2531,6 +2531,13 @@
|
|||||||
"summary": "Получить список всех отзывов.",
|
"summary": "Получить список всех отзывов.",
|
||||||
"description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin",
|
"description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "LlmStatus",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ReviewLlmStatus"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Page",
|
"name": "Page",
|
||||||
"in": "query",
|
"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": {
|
"/api/v1/reviews/{id}/reanalyze": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Reviews"
|
"Reviews"
|
||||||
],
|
],
|
||||||
"summary": "Запустить повторный LLM-анализ отзыва.",
|
"summary": "Запустить повторный LLM-анализ отзыва.",
|
||||||
"description": "Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его\nв очередь на повторную обработку фоновым сервисом.\n\n**Required roles:** Admin",
|
"description": "Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его\nна повторную обработку.\n\n**Required roles:** Admin",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "id",
|
"name": "id",
|
||||||
@@ -5582,6 +5525,10 @@
|
|||||||
},
|
},
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"llmRawOutput": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
"createdAt": {
|
"createdAt": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public record ReviewDto(
|
|||||||
double? QualityScore,
|
double? QualityScore,
|
||||||
bool? IsInformative,
|
bool? IsInformative,
|
||||||
string[]? LlmTags,
|
string[]? LlmTags,
|
||||||
|
string? LlmRawOutput,
|
||||||
DateTime CreatedAt
|
DateTime CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -22,6 +23,12 @@ public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Te
|
|||||||
|
|
||||||
public record UpdateReviewRequest(ReviewRating Rating, string? Text);
|
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 ReviewPromptDto(string Prompt, DateTime? UpdatedAt);
|
||||||
|
|
||||||
public record UpdateReviewPromptRequest(string Prompt);
|
public record UpdateReviewPromptRequest(string Prompt);
|
||||||
|
|||||||
@@ -3,5 +3,4 @@ namespace UniVerse.Application.Interfaces;
|
|||||||
public interface ILlmAnalysisService
|
public interface ILlmAnalysisService
|
||||||
{
|
{
|
||||||
Task AnalyzeReviewAsync(int reviewId);
|
Task AnalyzeReviewAsync(int reviewId);
|
||||||
Task ProcessPendingReviewsAsync();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ public record LlmReviewAnalysis(
|
|||||||
double QualityScore,
|
double QualityScore,
|
||||||
string Sentiment,
|
string Sentiment,
|
||||||
string[] Tags,
|
string[] Tags,
|
||||||
bool IsInformative
|
bool IsInformative,
|
||||||
|
string RawOutput
|
||||||
);
|
);
|
||||||
|
|
||||||
public interface ILlmClient
|
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 DeleteAsync(int id, int userId, bool isAdmin = false);
|
||||||
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByLectureAsync(int lectureId, PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetByUserAsync(int userId, PaginationRequest pagination);
|
||||||
Task<PagedResult<ReviewDto>> GetAllAsync(PaginationRequest pagination);
|
Task<PagedResult<ReviewDto>> GetAllAsync(ReviewFilterRequest filter);
|
||||||
Task<PagedResult<ReviewDto>> GetPendingAsync(PaginationRequest pagination);
|
|
||||||
Task ReanalyzeAsync(int id);
|
Task ReanalyzeAsync(int id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ public static class MappingExtensions
|
|||||||
review.UserId, review.User?.DisplayName,
|
review.UserId, review.User?.DisplayName,
|
||||||
review.Rating, review.Text, review.LlmStatus,
|
review.Rating, review.Text, review.LlmStatus,
|
||||||
review.Sentiment, review.QualityScore, review.IsInformative,
|
review.Sentiment, review.QualityScore, review.IsInformative,
|
||||||
review.LlmTags, review.CreatedAt
|
review.LlmTags, review.LlmRawOutput, review.CreatedAt
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Achievement ---
|
// --- Achievement ---
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public class Review
|
|||||||
public double? QualityScore { get; set; }
|
public double? QualityScore { get; set; }
|
||||||
public bool? IsInformative { get; set; }
|
public bool? IsInformative { get; set; }
|
||||||
public string[]? LlmTags { get; set; }
|
public string[]? LlmTags { get; set; }
|
||||||
|
public string? LlmRawOutput { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime UpdatedAt { 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.QualityScore).HasColumnName("quality_score");
|
||||||
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
|
builder.Property(r => r.IsInformative).HasColumnName("is_informative");
|
||||||
builder.Property(r => r.LlmTags).HasColumnName("llm_tags");
|
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.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("NOW()");
|
||||||
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
|
builder.Property(r => r.UpdatedAt).HasColumnName("updated_at").HasDefaultValueSql("NOW()");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using UniVerse.Application.Interfaces;
|
using UniVerse.Application.Interfaces;
|
||||||
@@ -49,11 +50,37 @@ public class LlmClient : ILlmClient
|
|||||||
|
|
||||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
var content = json.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString()!;
|
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 })!;
|
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)
|
.HasDefaultValue(0)
|
||||||
.HasColumnName("llm_status");
|
.HasColumnName("llm_status");
|
||||||
|
|
||||||
|
b.Property<string>("LlmRawOutput")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("llm_raw_output");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("LlmTags")
|
b.PrimitiveCollection<string[]>("LlmTags")
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("llm_tags");
|
.HasColumnName("llm_tags");
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ public class LlmAnalysisService : ILlmAnalysisService
|
|||||||
var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context);
|
var result = await _llm.AnalyzeReviewAsync(review.Text ?? "", context);
|
||||||
|
|
||||||
review.QualityScore = result.QualityScore;
|
review.QualityScore = result.QualityScore;
|
||||||
review.Sentiment = Enum.TryParse<ReviewSentiment>(result.Sentiment, true, out var s)
|
review.Sentiment = ParseSentiment(result.Sentiment);
|
||||||
? s : ReviewSentiment.Neutral;
|
|
||||||
review.LlmTags = result.Tags;
|
review.LlmTags = result.Tags;
|
||||||
review.IsInformative = result.IsInformative;
|
review.IsInformative = result.IsInformative;
|
||||||
|
review.LlmRawOutput = result.RawOutput;
|
||||||
review.LlmStatus = ReviewLlmStatus.Analyzed;
|
review.LlmStatus = ReviewLlmStatus.Analyzed;
|
||||||
review.UpdatedAt = DateTime.UtcNow;
|
review.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
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
|
var normalized = value.Trim().ToLowerInvariant();
|
||||||
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
|
return normalized switch
|
||||||
.OrderBy(r => r.CreatedAt).Take(10)
|
{
|
||||||
.Select(r => r.Id).ToListAsync();
|
"positive" or "положительный" or "положительная" or "позитивный" or "позитивная" => ReviewSentiment.Positive,
|
||||||
|
"negative" or "отрицательный" or "отрицательная" or "негативный" or "негативная" => ReviewSentiment.Negative,
|
||||||
foreach (var id in pending)
|
"neutral" or "нейтральный" or "нейтральная" => ReviewSentiment.Neutral,
|
||||||
await AnalyzeReviewAsync(id);
|
_ 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 AppDbContext _db;
|
||||||
private readonly IGamificationService _gamification;
|
private readonly IGamificationService _gamification;
|
||||||
|
private readonly IReviewAnalysisQueue _reviewAnalysisQueue;
|
||||||
|
|
||||||
public ReviewService(AppDbContext db, IGamificationService gamification)
|
public ReviewService(
|
||||||
|
AppDbContext db,
|
||||||
|
IGamificationService gamification,
|
||||||
|
IReviewAnalysisQueue reviewAnalysisQueue)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_gamification = gamification;
|
_gamification = gamification;
|
||||||
|
_reviewAnalysisQueue = reviewAnalysisQueue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<Review> BaseQuery() => _db.Reviews
|
private IQueryable<Review> BaseQuery() => _db.Reviews
|
||||||
@@ -38,6 +43,7 @@ public class ReviewService : IReviewService
|
|||||||
_db.Reviews.Add(review);
|
_db.Reviews.Add(review);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
await _gamification.CheckAndAwardAchievementsAsync(userId);
|
||||||
|
await _reviewAnalysisQueue.EnqueueAsync(review.Id);
|
||||||
var full = await BaseQuery().FirstAsync(r => r.Id == review.Id);
|
var full = await BaseQuery().FirstAsync(r => r.Id == review.Id);
|
||||||
return full.ToDto();
|
return full.ToDto();
|
||||||
}
|
}
|
||||||
@@ -54,9 +60,10 @@ public class ReviewService : IReviewService
|
|||||||
var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id);
|
var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id);
|
||||||
if (review.UserId != userId) throw new ForbiddenException();
|
if (review.UserId != userId) throw new ForbiddenException();
|
||||||
review.Rating = req.Rating; review.Text = req.Text;
|
review.Rating = req.Rating; review.Text = req.Text;
|
||||||
review.LlmStatus = ReviewLlmStatus.Pending;
|
ResetLlmAnalysis(review);
|
||||||
review.UpdatedAt = DateTime.UtcNow;
|
review.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
await _reviewAnalysisQueue.EnqueueAsync(review.Id);
|
||||||
return await GetByIdAsync(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);
|
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();
|
var query = BaseQuery();
|
||||||
|
if (filter.LlmStatus.HasValue)
|
||||||
|
query = query.Where(r => r.LlmStatus == filter.LlmStatus.Value);
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
var items = await query.OrderByDescending(r => r.CreatedAt)
|
var items = await query.OrderByDescending(r => r.CreatedAt)
|
||||||
.Skip((pagination.Page - 1) * pagination.PageSize).Take(pagination.PageSize).ToListAsync();
|
.Skip((filter.Page - 1) * filter.PageSize).Take(filter.PageSize).ToListAsync();
|
||||||
return PagedResult<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, pagination.Page, pagination.PageSize);
|
return PagedResult<ReviewDto>.Create(items.Select(r => r.ToDto()).ToList(), total, filter.Page, filter.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ReanalyzeAsync(int id)
|
public async Task ReanalyzeAsync(int id)
|
||||||
{
|
{
|
||||||
var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id);
|
var review = await _db.Reviews.FindAsync(id) ?? throw new NotFoundException("Review", id);
|
||||||
review.LlmStatus = ReviewLlmStatus.Pending;
|
ResetLlmAnalysis(review);
|
||||||
review.UpdatedAt = DateTime.UtcNow;
|
review.UpdatedAt = DateTime.UtcNow;
|
||||||
await _db.SaveChangesAsync();
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,13 +172,6 @@ async function listReviewsPage(query: ReviewQuery = {}) {
|
|||||||
return normalizePagedResult(payload, query)
|
return normalizePagedResult(payload, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listPendingReviewsPage(query: ReviewQuery = {}) {
|
|
||||||
const payload = await apiRequest<PagedResult<ReviewDto> | ReviewDto[]>('/reviews/pending', {
|
|
||||||
query: query as Record<string, unknown>,
|
|
||||||
})
|
|
||||||
return normalizePagedResult(payload, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const reviewsApi = {
|
export const reviewsApi = {
|
||||||
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
create: (lectureId: string | number, rating: 'Like' | 'Neutral' | 'Dislike', text: string) =>
|
||||||
apiRequest<ReviewDto>('/reviews', {
|
apiRequest<ReviewDto>('/reviews', {
|
||||||
@@ -195,10 +188,6 @@ export const reviewsApi = {
|
|||||||
async list(query: ReviewQuery = { PageSize: 100 }) {
|
async list(query: ReviewQuery = { PageSize: 100 }) {
|
||||||
return (await listReviewsPage(query)).items
|
return (await listReviewsPage(query)).items
|
||||||
},
|
},
|
||||||
pendingPage: listPendingReviewsPage,
|
|
||||||
async pending(query: ReviewQuery = { PageSize: 100 }) {
|
|
||||||
return (await listPendingReviewsPage(query)).items
|
|
||||||
},
|
|
||||||
reanalyze: (id: string | number) =>
|
reanalyze: (id: string | number) =>
|
||||||
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
apiRequest<void>(`/reviews/${id}/reanalyze`, { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,8 @@ export function mapApiLecture(lecture: LectureDto): Lecture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapApiReview(review: ReviewDto): Review {
|
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 =
|
const status =
|
||||||
review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending'
|
review.llmStatus === 'Rejected' ? 'rejected' : review.llmStatus === 'Analyzed' ? 'done' : 'pending'
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export interface CreateLectureRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewQuery {
|
export interface ReviewQuery {
|
||||||
|
LlmStatus?: ApiReviewLlmStatus
|
||||||
Page?: number
|
Page?: number
|
||||||
PageSize?: number
|
PageSize?: number
|
||||||
}
|
}
|
||||||
@@ -129,10 +130,11 @@ export interface ReviewDto {
|
|||||||
rating: ApiReviewRating
|
rating: ApiReviewRating
|
||||||
text?: string | null
|
text?: string | null
|
||||||
llmStatus: ApiReviewLlmStatus
|
llmStatus: ApiReviewLlmStatus
|
||||||
sentiment: ApiReviewSentiment
|
sentiment?: ApiReviewSentiment | null
|
||||||
qualityScore?: number | null
|
qualityScore?: number | null
|
||||||
isInformative?: boolean | null
|
isInformative?: boolean | null
|
||||||
llmTags?: string[] | null
|
llmTags?: string[] | null
|
||||||
|
llmRawOutput?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const navItems = computed(() => {
|
|||||||
{ label: 'Дашборд', icon: 'shield', to: '/admin' },
|
{ label: 'Дашборд', icon: 'shield', to: '/admin' },
|
||||||
{ label: 'Юзеры', icon: 'users', to: '/admin/users' },
|
{ label: 'Юзеры', icon: 'users', to: '/admin/users' },
|
||||||
{ label: 'Лекции', icon: 'books', to: '/admin/lectures' },
|
{ label: 'Лекции', icon: 'books', to: '/admin/lectures' },
|
||||||
{ label: 'ИИ', icon: 'robot', to: '/admin/llm-queue' },
|
{ label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews' },
|
||||||
]
|
]
|
||||||
return [
|
return [
|
||||||
{ label: 'Главная', icon: 'home', to: '/' },
|
{ label: 'Главная', icon: 'home', to: '/' },
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const navItems: NavItem[] = [
|
|||||||
{ label: 'Дашборд', icon: 'shield', to: '/admin', roles: ['admin'] },
|
{ label: 'Дашборд', icon: 'shield', to: '/admin', roles: ['admin'] },
|
||||||
{ label: 'Пользователи', icon: 'users', to: '/admin/users', roles: ['admin'] },
|
{ label: 'Пользователи', icon: 'users', to: '/admin/users', roles: ['admin'] },
|
||||||
{ label: 'Лекции', icon: 'books', to: '/admin/lectures', 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'] },
|
{ label: 'Отзывы', icon: 'message-circle', to: '/admin/reviews', roles: ['admin'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const router = createRouter({
|
|||||||
{ path: '/admin', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue'), meta: { role: 'admin' } },
|
{ 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/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/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: '/admin/reviews', name: 'admin-reviews', component: () => import('@/views/admin/AdminReviewsView.vue'), meta: { role: 'admin' } },
|
||||||
|
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
import GlassCard from '@/components/ui/GlassCard.vue'
|
||||||
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
import StatsWidget from '@/components/ui/StatsWidget.vue'
|
||||||
import ProgressBar from '@/components/ui/ProgressBar.vue'
|
|
||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
import { lecturesApi, reviewsApi, syncApi, usersApi } from '@/api'
|
||||||
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
import type { LectureDto, SyncStatusDto, UserDto } from '@/api/types'
|
||||||
@@ -25,7 +24,7 @@ onMounted(async () => {
|
|||||||
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
const [usersResult, lecturesResult, reviewsResult, syncResult] = await Promise.allSettled([
|
||||||
usersApi.list({ PageSize: 100 }),
|
usersApi.list({ PageSize: 100 }),
|
||||||
lecturesApi.list({ PageSize: 100 }),
|
lecturesApi.list({ PageSize: 100 }),
|
||||||
reviewsApi.pendingPage({ Page: 1, PageSize: 1 }),
|
reviewsApi.listPage({ Page: 1, PageSize: 1, LlmStatus: 'Pending' }),
|
||||||
syncApi.status(),
|
syncApi.status(),
|
||||||
])
|
])
|
||||||
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
if (usersResult.status === 'fulfilled') users.value = usersResult.value
|
||||||
@@ -45,7 +44,7 @@ onMounted(async () => {
|
|||||||
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
<StatsWidget label="Лекций" :value="lectures.length" icon="books" color="aqua" />
|
||||||
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
<StatsWidget label="Записей" :value="enrollmentCount" icon="calendar-event" color="orange" />
|
||||||
<StatsWidget
|
<StatsWidget
|
||||||
label="Отзывов в LLM"
|
label="Отзывы на проверке"
|
||||||
:value="pendingReviewsCount"
|
:value="pendingReviewsCount"
|
||||||
icon="message-circle"
|
icon="message-circle"
|
||||||
color="purple"
|
color="purple"
|
||||||
@@ -61,12 +60,6 @@ onMounted(async () => {
|
|||||||
Ошибка: {{ syncStatus.lastResult.error }}
|
Ошибка: {{ syncStatus.lastResult.error }}
|
||||||
</div>
|
</div>
|
||||||
</GlassCard>
|
</GlassCard>
|
||||||
<GlassCard>
|
|
||||||
<div class="section-title">Очередь LLM-анализа</div>
|
|
||||||
<div class="queue-meta">В очереди: {{ pendingReviewsCount }} отзывов</div>
|
|
||||||
<ProgressBar :value="Math.min(pendingReviewsCount * 10, 100)" :max="100" />
|
|
||||||
<div class="queue-status">Следующая проверка через 12 минут</div>
|
|
||||||
</GlassCard>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -147,14 +140,4 @@ onMounted(async () => {
|
|||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
margin-top: 8px;
|
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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import GlassCard from '@/components/ui/GlassCard.vue'
|
|
||||||
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 { mapApiReview } from '@/api/mappers'
|
|
||||||
import type { Review } from '@/types'
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ key: 'id', label: 'ID' },
|
|
||||||
{ key: 'lecture', label: 'Лекция' },
|
|
||||||
{ key: 'student', label: 'Студент' },
|
|
||||||
{ key: 'date', label: 'Дата' },
|
|
||||||
{ key: 'status', label: 'Статус', align: 'center' },
|
|
||||||
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
|
|
||||||
{ key: 'quality', label: 'Качество', align: 'center' },
|
|
||||||
{ key: 'coins', label: 'Монеты', align: 'center' },
|
|
||||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const reviews = ref<Review[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
const totalCount = ref(0)
|
|
||||||
const totalPages = ref(0)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
const rows = computed(() =>
|
|
||||||
reviews.value.map((review) => ({
|
|
||||||
id: review.id,
|
|
||||||
lecture: review.lectureId,
|
|
||||||
student: review.userName,
|
|
||||||
date: new Date(review.createdAt).toLocaleDateString('ru-RU'),
|
|
||||||
status: review.status,
|
|
||||||
sentiment: review.sentiment,
|
|
||||||
quality: review.quality ?? 0,
|
|
||||||
coins: review.coins ?? 0,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageStart = computed(() =>
|
|
||||||
totalCount.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1,
|
|
||||||
)
|
|
||||||
const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value))
|
|
||||||
const canGoPrev = computed(() => page.value > 1)
|
|
||||||
const canGoNext = computed(() => page.value < totalPages.value)
|
|
||||||
|
|
||||||
async function fetchPending() {
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const result = await reviewsApi.pendingPage({
|
|
||||||
Page: page.value,
|
|
||||||
PageSize: pageSize.value,
|
|
||||||
})
|
|
||||||
reviews.value = result.items.map(mapApiReview)
|
|
||||||
totalCount.value = result.totalCount
|
|
||||||
totalPages.value = result.totalPages
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить очередь LLM.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToPage(nextPage: number) {
|
|
||||||
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
|
|
||||||
page.value = nextPage
|
|
||||||
await fetchPending()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reanalyze(id: string) {
|
|
||||||
await reviewsApi.reanalyze(id)
|
|
||||||
await fetchPending()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(fetchPending)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="admin-llm page-content">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
|
|
||||||
<button class="btn-primary" :disabled="loading" @click="fetchPending">
|
|
||||||
{{ loading ? 'Загрузка...' : 'Обновить очередь' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GlassCard>
|
|
||||||
<EmptyState v-if="error" title="Не удалось загрузить очередь" :subtitle="error" />
|
|
||||||
<EmptyState
|
|
||||||
v-else-if="!rows.length && !loading"
|
|
||||||
title="Очередь пуста"
|
|
||||||
subtitle="Нет отзывов, ожидающих LLM-анализ."
|
|
||||||
/>
|
|
||||||
<div v-else class="table-section">
|
|
||||||
<div class="pagination-bar">
|
|
||||||
<span> В очереди {{ totalCount }} отзывов; показаны {{ pageStart }}–{{ pageEnd }} </span>
|
|
||||||
<div class="pagination-actions">
|
|
||||||
<button class="btn-ghost" :disabled="loading || !canGoPrev" @click="goToPage(page - 1)">
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
<span>Страница {{ page }} из {{ totalPages || 1 }}</span>
|
|
||||||
<button class="btn-ghost" :disabled="loading || !canGoNext" @click="goToPage(page + 1)">
|
|
||||||
Вперёд
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DataTable :columns="columns" :rows="rows">
|
|
||||||
<template #status="{ value }">
|
|
||||||
<StatusBadge :status="value" />
|
|
||||||
</template>
|
|
||||||
<template #quality="{ value }">
|
|
||||||
<span
|
|
||||||
:class="
|
|
||||||
value >= 0.7
|
|
||||||
? 'badge badge-green'
|
|
||||||
: value >= 0.4
|
|
||||||
? 'badge badge-orange'
|
|
||||||
: 'badge badge-red'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ value }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #actions="{ row }">
|
|
||||||
<button class="btn-ghost" @click="reanalyze(row.id)">Повторить</button>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</GlassCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-llm {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.table-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.pagination-bar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.pagination-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,7 +5,7 @@ import DataTable from '@/components/ui/DataTable.vue'
|
|||||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue'
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
||||||
import { reviewsApi } from '@/api'
|
import { reviewsApi } from '@/api'
|
||||||
import type { ReviewDto } from '@/api/types'
|
import type { ApiReviewLlmStatus, ApiReviewSentiment, ReviewDto } from '@/api/types'
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'id', label: 'ID' },
|
{ key: 'id', label: 'ID' },
|
||||||
@@ -14,10 +14,7 @@ const columns = [
|
|||||||
{ key: 'rating', label: 'Оценка', align: 'center' },
|
{ key: 'rating', label: 'Оценка', align: 'center' },
|
||||||
{ key: 'text', label: 'Текст' },
|
{ key: 'text', label: 'Текст' },
|
||||||
{ key: 'date', label: 'Дата' },
|
{ key: 'date', label: 'Дата' },
|
||||||
{ key: 'status', label: 'LLM-статус', align: 'center' },
|
{ key: 'analysis', label: 'Результат нейронки' },
|
||||||
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
|
|
||||||
{ key: 'quality', label: 'Quality', align: 'center' },
|
|
||||||
{ key: 'informativeTags', label: 'Informative / tags' },
|
|
||||||
{ key: 'actions', label: 'Действия', align: 'right' },
|
{ key: 'actions', label: 'Действия', align: 'right' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -33,12 +30,32 @@ const ratingLabel: Record<string, string> = {
|
|||||||
Dislike: '👎 Dislike',
|
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<ApiReviewSentiment, string> = {
|
||||||
|
Positive: 'Позитивный',
|
||||||
|
Neutral: 'Нейтральный',
|
||||||
|
Negative: 'Негативный',
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentimentClass: Record<ApiReviewSentiment, string> = {
|
||||||
|
Positive: 'badge-green',
|
||||||
|
Neutral: 'badge-orange',
|
||||||
|
Negative: 'badge-red',
|
||||||
|
}
|
||||||
|
|
||||||
const reviews = ref<ReviewDto[]>([])
|
const reviews = ref<ReviewDto[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const totalCount = ref(0)
|
const totalCount = ref(0)
|
||||||
const totalPages = ref(0)
|
const totalPages = ref(0)
|
||||||
|
const statusFilter = ref<ApiReviewLlmStatus | 'All'>('All')
|
||||||
const reanalyzingId = ref<number | null>(null)
|
const reanalyzingId = ref<number | null>(null)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const promptText = ref('')
|
const promptText = ref('')
|
||||||
@@ -50,7 +67,12 @@ const promptError = ref('')
|
|||||||
const promptSuccess = ref('')
|
const promptSuccess = ref('')
|
||||||
|
|
||||||
const rows = computed(() =>
|
const rows = computed(() =>
|
||||||
reviews.value.map((review) => ({
|
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,
|
id: review.id,
|
||||||
lecture: review.lectureTitle || `#${review.lectureId}`,
|
lecture: review.lectureTitle || `#${review.lectureId}`,
|
||||||
student: review.userName || `#${review.userId}`,
|
student: review.userName || `#${review.userId}`,
|
||||||
@@ -59,19 +81,22 @@ const rows = computed(() =>
|
|||||||
date: new Date(review.createdAt).toLocaleString('ru-RU'),
|
date: new Date(review.createdAt).toLocaleString('ru-RU'),
|
||||||
status: statusMap[review.llmStatus] ?? review.llmStatus,
|
status: statusMap[review.llmStatus] ?? review.llmStatus,
|
||||||
rawStatus: review.llmStatus,
|
rawStatus: review.llmStatus,
|
||||||
sentiment: review.sentiment,
|
analysis: review.llmStatus,
|
||||||
quality: review.qualityScore,
|
analysisReady,
|
||||||
informative: review.isInformative,
|
analysisMessage: getAnalysisMessage(review),
|
||||||
tags: review.llmTags ?? [],
|
sentiment,
|
||||||
informativeTags: [
|
sentimentLabel: sentiment ? sentimentLabel[sentiment] : '—',
|
||||||
review.isInformative === null || review.isInformative === undefined
|
sentimentClass: sentiment ? sentimentClass[sentiment] : 'badge-gray',
|
||||||
? '—'
|
quality: analysisReady ? review.qualityScore : null,
|
||||||
: review.isInformative
|
qualityLabel: analysisReady ? formatQuality(review.qualityScore) : '—',
|
||||||
? 'Да'
|
qualityClass: getQualityClass(analysisReady ? review.qualityScore : null),
|
||||||
: 'Нет',
|
informative: analysisReady ? review.isInformative : null,
|
||||||
...(review.llmTags ?? []),
|
informativeLabel: analysisReady ? formatInformative(review.isInformative) : '—',
|
||||||
].join(' / '),
|
informativeClass: getInformativeClass(analysisReady ? review.isInformative : null),
|
||||||
})),
|
tags,
|
||||||
|
llmRawOutput: analysisReady ? formatRawOutput(review.llmRawOutput) : '',
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const pageStart = computed(() =>
|
const pageStart = computed(() =>
|
||||||
@@ -93,9 +118,44 @@ const canSavePrompt = computed(
|
|||||||
promptText.value !== savedPromptText.value,
|
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) {
|
function formatQuality(value: number | null | undefined) {
|
||||||
if (value === null || value === undefined) return '—'
|
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() {
|
async function fetchPrompt() {
|
||||||
@@ -144,6 +204,7 @@ async function fetchReviews() {
|
|||||||
const result = await reviewsApi.listPage({
|
const result = await reviewsApi.listPage({
|
||||||
Page: page.value,
|
Page: page.value,
|
||||||
PageSize: pageSize.value,
|
PageSize: pageSize.value,
|
||||||
|
...(statusFilter.value === 'All' ? {} : { LlmStatus: statusFilter.value }),
|
||||||
})
|
})
|
||||||
reviews.value = result.items
|
reviews.value = result.items
|
||||||
totalCount.value = result.totalCount
|
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) {
|
async function goToPage(nextPage: number) {
|
||||||
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
|
if (nextPage < 1 || (totalPages.value && nextPage > totalPages.value)) return
|
||||||
page.value = nextPage
|
page.value = nextPage
|
||||||
@@ -224,6 +292,21 @@ onMounted(() => {
|
|||||||
</GlassCard>
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
|
<div class="review-toolbar">
|
||||||
|
<div class="status-filters" aria-label="Фильтр статуса LLM">
|
||||||
|
<button
|
||||||
|
v-for="filter in statusFilters"
|
||||||
|
:key="filter.value"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: statusFilter === filter.value }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="selectStatusFilter(filter.value)"
|
||||||
|
>
|
||||||
|
{{ filter.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
|
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-else-if="!rows.length && !loading"
|
v-else-if="!rows.length && !loading"
|
||||||
@@ -247,59 +330,34 @@ onMounted(() => {
|
|||||||
<template #text="{ value }">
|
<template #text="{ value }">
|
||||||
<span class="review-text" :title="value">{{ value }}</span>
|
<span class="review-text" :title="value">{{ value }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #status="{ value, row }">
|
<template #analysis="{ row }">
|
||||||
<StatusBadge :status="value" />
|
<div class="analysis-cell">
|
||||||
|
<div v-if="!row.analysisReady" class="analysis-head">
|
||||||
|
<StatusBadge :status="row.status" />
|
||||||
<span class="raw-status">{{ row.rawStatus }}</span>
|
<span class="raw-status">{{ row.rawStatus }}</span>
|
||||||
</template>
|
</div>
|
||||||
<template #sentiment="{ value }">
|
<div v-if="row.analysisReady" class="analysis-result">
|
||||||
<span
|
<div class="analysis-metrics">
|
||||||
class="badge"
|
<span class="badge" :class="row.qualityClass">
|
||||||
:class="
|
Качество {{ row.qualityLabel }}
|
||||||
value === 'Positive'
|
|
||||||
? 'badge-green'
|
|
||||||
: value === 'Negative'
|
|
||||||
? 'badge-red'
|
|
||||||
: 'badge-orange'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ value }}
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
<span class="badge" :class="row.sentimentClass">{{ row.sentimentLabel }}</span>
|
||||||
<template #quality="{ value }">
|
<span class="badge" :class="row.informativeClass">
|
||||||
<span
|
{{ row.informativeLabel }}
|
||||||
:class="
|
|
||||||
(value ?? 0) >= 0.7
|
|
||||||
? 'badge badge-green'
|
|
||||||
: (value ?? 0) >= 0.4
|
|
||||||
? 'badge badge-orange'
|
|
||||||
: 'badge badge-red'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ formatQuality(value) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #informativeTags="{ row }">
|
|
||||||
<div class="tags-cell">
|
|
||||||
<span
|
|
||||||
class="badge"
|
|
||||||
:class="
|
|
||||||
row.informative
|
|
||||||
? 'badge-green'
|
|
||||||
: row.informative === false
|
|
||||||
? 'badge-red'
|
|
||||||
: 'badge-gray'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
row.informative === null || row.informative === undefined
|
|
||||||
? '—'
|
|
||||||
: row.informative
|
|
||||||
? 'Informative'
|
|
||||||
: 'Not informative'
|
|
||||||
}}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.tags.length" class="analysis-tags">
|
||||||
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
|
<span v-for="tag in row.tags" :key="tag" class="badge badge-blue">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span v-else class="analysis-note">Теги не найдены</span>
|
||||||
|
<details v-if="row.llmRawOutput" class="raw-output">
|
||||||
|
<summary>Текст ответа нейронки</summary>
|
||||||
|
<pre>{{ row.llmRawOutput }}</pre>
|
||||||
|
</details>
|
||||||
|
<span v-else class="analysis-note">Текст ответа нейронки не сохранён</span>
|
||||||
|
</div>
|
||||||
|
<span v-else class="analysis-note">{{ row.analysisMessage }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<button
|
<button
|
||||||
@@ -405,8 +463,7 @@ onMounted(() => {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.raw-status {
|
.raw-status {
|
||||||
display: block;
|
display: inline-flex;
|
||||||
margin-top: 4px;
|
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
@@ -415,6 +472,34 @@ onMounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
.review-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.status-filters {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--color-border-glass);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.status-filters button {
|
||||||
|
background: var(--color-white-a70);
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
.status-filters button.active {
|
||||||
|
background: var(--color-primary-a18);
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-filters button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
.pagination-bar {
|
.pagination-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -429,10 +514,50 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.tags-cell {
|
.analysis-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.analysis-head,
|
||||||
|
.analysis-metrics,
|
||||||
|
.analysis-tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
min-width: 160px;
|
}
|
||||||
|
.analysis-result {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.analysis-note {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.raw-output {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
.raw-output summary {
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.raw-output pre {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
border: 1px solid var(--color-border-glass);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-white-a60);
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user