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:
@@ -1,36 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public class LlmProcessingBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ILogger<LlmProcessingBackgroundService> _logger;
|
||||
|
||||
public LlmProcessingBackgroundService(IServiceProvider services, ILogger<LlmProcessingBackgroundService> logger)
|
||||
{
|
||||
_services = services; _logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LLM Processing Background Service started");
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
|
||||
await llmService.ProcessPendingReviewsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in LLM processing background service");
|
||||
}
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading.Channels;
|
||||
using UniVerse.Application.Interfaces;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public sealed class ReviewAnalysisQueue : IReviewAnalysisQueue
|
||||
{
|
||||
private readonly Channel<int> _channel = Channel.CreateUnbounded<int>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = false,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
public async Task EnqueueAsync(int reviewId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _channel.Writer.WriteAsync(reviewId, cancellationToken);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<int> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using UniVerse.Api.Options;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Domain.Enums;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
|
||||
namespace UniVerse.Api.BackgroundServices;
|
||||
|
||||
public sealed class ReviewAnalysisWorker : BackgroundService
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly ReviewAnalysisQueue _queue;
|
||||
private readonly ReviewAnalysisOptions _options;
|
||||
private readonly ILogger<ReviewAnalysisWorker> _logger;
|
||||
|
||||
public ReviewAnalysisWorker(
|
||||
IServiceProvider services,
|
||||
ReviewAnalysisQueue queue,
|
||||
IOptions<ReviewAnalysisOptions> options,
|
||||
ILogger<ReviewAnalysisWorker> logger)
|
||||
{
|
||||
_services = services;
|
||||
_queue = queue;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var maxConcurrency = Math.Max(1, _options.MaxConcurrentProcessing);
|
||||
_logger.LogInformation(
|
||||
"Review analysis worker started with max concurrency {MaxConcurrency}",
|
||||
maxConcurrency);
|
||||
|
||||
await EnqueueExistingPendingReviewsAsync(stoppingToken);
|
||||
|
||||
var workers = Enumerable.Range(1, maxConcurrency)
|
||||
.Select(workerNumber => ProcessQueueAsync(workerNumber, stoppingToken))
|
||||
.ToArray();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(workers);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Review analysis worker stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnqueueExistingPendingReviewsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
var pendingReviewIds = await db.Reviews
|
||||
.Where(r => r.LlmStatus == ReviewLlmStatus.Pending)
|
||||
.OrderBy(r => r.CreatedAt)
|
||||
.Select(r => r.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var reviewId in pendingReviewIds)
|
||||
await _queue.EnqueueAsync(reviewId, cancellationToken);
|
||||
|
||||
if (pendingReviewIds.Count > 0)
|
||||
_logger.LogInformation(
|
||||
"Queued {ReviewCount} pending reviews for immediate analysis",
|
||||
pendingReviewIds.Count);
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync(int workerNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var reviewId in _queue.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _services.CreateScope();
|
||||
var llmService = scope.ServiceProvider.GetRequiredService<ILlmAnalysisService>();
|
||||
await llmService.AnalyzeReviewAsync(reviewId);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Review analysis worker {WorkerNumber} failed to process review {ReviewId}",
|
||||
workerNumber,
|
||||
reviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class ReviewsController : ControllerBase
|
||||
|
||||
/// <summary>Создать отзыв к лекции.</summary>
|
||||
/// <remarks>
|
||||
/// Только Student. После создания отзыв помещается в очередь LLM-анализа
|
||||
/// Только Student. После создания отзыв отправляется на LLM-анализ
|
||||
/// (статус `Pending`). LLM оценивает содержательность и начисляет монеты
|
||||
/// скрытно от пользователя.
|
||||
/// </remarks>
|
||||
@@ -50,7 +50,7 @@ public class ReviewsController : ControllerBase
|
||||
|
||||
/// <summary>Получить список всех отзывов.</summary>
|
||||
/// <remarks>Только Admin. Возвращает все отзывы независимо от LLM-статуса.</remarks>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <param name="filter">Параметры фильтрации и пагинации.</param>
|
||||
/// <response code="200">Список всех отзывов (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
@@ -59,8 +59,8 @@ public class ReviewsController : ControllerBase
|
||||
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetAllAsync(pagination));
|
||||
public async Task<ActionResult> List([FromQuery] ReviewFilterRequest filter) =>
|
||||
Ok(await _reviews.GetAllAsync(filter));
|
||||
|
||||
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
|
||||
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
|
||||
@@ -143,24 +143,10 @@ public class ReviewsController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>Получить список отзывов, ожидающих LLM-анализа.</summary>
|
||||
/// <remarks>Только Admin. Используется для мониторинга очереди обработки.</remarks>
|
||||
/// <param name="pagination">Параметры пагинации.</param>
|
||||
/// <response code="200">Список отзывов со статусом Pending (пагинированный).</response>
|
||||
/// <response code="401">Требуется аутентификация.</response>
|
||||
/// <response code="403">Требуется роль Admin.</response>
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet("pending")]
|
||||
[ProducesResponseType(typeof(PagedResult<ReviewDto>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||
public async Task<ActionResult> Pending([FromQuery] PaginationRequest pagination) =>
|
||||
Ok(await _reviews.GetPendingAsync(pagination));
|
||||
|
||||
/// <summary>Запустить повторный LLM-анализ отзыва.</summary>
|
||||
/// <remarks>
|
||||
/// Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его
|
||||
/// в очередь на повторную обработку фоновым сервисом.
|
||||
/// Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его
|
||||
/// на повторную обработку.
|
||||
/// </remarks>
|
||||
/// <param name="id">ID отзыва.</param>
|
||||
/// <response code="204">Повторный анализ запланирован.</response>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace UniVerse.Api.Options;
|
||||
|
||||
public class ReviewAnalysisOptions
|
||||
{
|
||||
public const string SectionName = "Llm:ReviewAnalysis";
|
||||
|
||||
public int MaxConcurrentProcessing { get; set; } = 1;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Serilog;
|
||||
using UniVerse.Api.BackgroundServices;
|
||||
using UniVerse.Api.Filters;
|
||||
using UniVerse.Api.Middleware;
|
||||
using UniVerse.Api.Options;
|
||||
using UniVerse.Application.Interfaces;
|
||||
using UniVerse.Infrastructure.Services;
|
||||
using UniVerse.Infrastructure.Data;
|
||||
@@ -97,8 +98,15 @@ builder.Services.AddScoped<IScheduleSyncService, ScheduleSyncService>();
|
||||
builder.Services.AddScoped<INotificationService, NotificationService>();
|
||||
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
|
||||
builder.Services.AddSingleton<INotificationScheduler, QuartzNotificationScheduler>();
|
||||
builder.Services.AddSingleton<ReviewAnalysisQueue>();
|
||||
builder.Services.AddSingleton<IReviewAnalysisQueue>(sp => sp.GetRequiredService<ReviewAnalysisQueue>());
|
||||
builder.Services.AddTransient<NotificationJob>();
|
||||
builder.Services.Configure<EmailNotificationOptions>(builder.Configuration.GetSection("Email:Smtp"));
|
||||
builder.Services.AddOptions<ReviewAnalysisOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ReviewAnalysisOptions.SectionName))
|
||||
.Validate(options => options.MaxConcurrentProcessing >= 1,
|
||||
"Llm:ReviewAnalysis:MaxConcurrentProcessing must be greater than or equal to 1.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddQuartz();
|
||||
if (!isOpenApiGeneration)
|
||||
@@ -132,7 +140,7 @@ builder.Services.AddHttpClient<IModeusApiClient, ModeusApiClient>(client =>
|
||||
// --- Background Services ---
|
||||
if (!isOpenApiGeneration)
|
||||
{
|
||||
builder.Services.AddHostedService<LlmProcessingBackgroundService>();
|
||||
builder.Services.AddHostedService<ReviewAnalysisWorker>();
|
||||
builder.Services.AddHostedService<AchievementCatalogHostedService>();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
"Llm": {
|
||||
"BaseUrl": "https://api.openai.com/v1/",
|
||||
"ApiKey": "",
|
||||
"Model": "gpt-4o-mini"
|
||||
"Model": "gpt-4o-mini",
|
||||
"ReviewAnalysis": {
|
||||
"MaxConcurrentProcessing": 1
|
||||
}
|
||||
},
|
||||
"ModeusApi": {
|
||||
"BaseUrl": "https://schedule.rdcenter.ru",
|
||||
|
||||
@@ -2445,7 +2445,7 @@
|
||||
"Reviews"
|
||||
],
|
||||
"summary": "Создать отзыв к лекции.",
|
||||
"description": "Только Student. После создания отзыв помещается в очередь LLM-анализа\n(статус `Pending`). LLM оценивает содержательность и начисляет монеты\nскрытно от пользователя.\n\n**Required roles:** Student",
|
||||
"description": "Только Student. После создания отзыв отправляется на LLM-анализ\n(статус `Pending`). LLM оценивает содержательность и начисляет монеты\nскрытно от пользователя.\n\n**Required roles:** Student",
|
||||
"requestBody": {
|
||||
"description": "ID лекции, оценка (Like/Neutral/Dislike) и текст отзыва.",
|
||||
"content": {
|
||||
@@ -2531,6 +2531,13 @@
|
||||
"summary": "Получить список всех отзывов.",
|
||||
"description": "Только Admin. Возвращает все отзывы независимо от LLM-статуса.\n\n**Required roles:** Admin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "LlmStatus",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReviewLlmStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Page",
|
||||
"in": "query",
|
||||
@@ -2920,77 +2927,13 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/reviews/pending": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Reviews"
|
||||
],
|
||||
"summary": "Получить список отзывов, ожидающих LLM-анализа.",
|
||||
"description": "Только Admin. Используется для мониторинга очереди обработки.\n\n**Required roles:** Admin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "PageSize",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Список отзывов со статусом Pending (пагинированный).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReviewDtoPagedResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Требуется аутентификация.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Требуется роль Admin.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Bearer": [ ]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/reviews/{id}/reanalyze": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Reviews"
|
||||
],
|
||||
"summary": "Запустить повторный LLM-анализ отзыва.",
|
||||
"description": "Только Admin. Сбрасывает статус отзыва на `Pending` и ставит его\nв очередь на повторную обработку фоновым сервисом.\n\n**Required roles:** Admin",
|
||||
"description": "Только Admin. Сбрасывает статус отзыва на `Pending` и отправляет его\nна повторную обработку.\n\n**Required roles:** Admin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -5582,6 +5525,10 @@
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"llmRawOutput": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
|
||||
Reference in New Issue
Block a user