From 935e4ed37a5dc8def83194109a5e073530b27a99 Mon Sep 17 00:00:00 2001 From: Sergey Karmanov Date: Thu, 21 May 2026 21:58:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=BC=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../EndpointAuthorizationTests.cs | 3 + .../Helpers/ApiWebApplicationFactory.cs | 14 + .../Reviews/ReviewPromptServiceTests.cs | 138 ++ .../Controllers/ReviewsController.cs | 36 +- .../Middleware/ExceptionHandlingMiddleware.cs | 1 + backend/UniVerse.Api/Program.cs | 1 + backend/UniVerse.Api/openapi.json | 145 +++ .../DTOs/Reviews/ReviewDtos.cs | 4 + .../Interfaces/IReviewPromptService.cs | 9 + .../Prompts/ReviewPromptTemplate.cs | 27 + .../Entities/ReviewPromptSetting.cs | 11 + .../Exceptions/BadRequestException.cs | 8 + .../Data/AppDbContext.cs | 1 + .../ReviewPromptSettingConfiguration.cs | 27 + .../ExternalServices/LlmClient.cs | 26 +- ...521170452_ReviewPromptSettings.Designer.cs | 1135 +++++++++++++++++ .../20260521170452_ReviewPromptSettings.cs | 36 + .../Migrations/AppDbContextModelSnapshot.cs | 28 + .../Services/ReviewPromptService.cs | 70 + frontend/src/api/index.ts | 8 + frontend/src/api/types.ts | 9 + frontend/src/views/admin/AdminReviewsView.vue | 158 ++- 22 files changed, 1880 insertions(+), 15 deletions(-) create mode 100644 backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs create mode 100644 backend/UniVerse.Application/Interfaces/IReviewPromptService.cs create mode 100644 backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs create mode 100644 backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs create mode 100644 backend/UniVerse.Domain/Exceptions/BadRequestException.cs create mode 100644 backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs create mode 100644 backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs create mode 100644 backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs diff --git a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs index 8f2647b..d84abae 100644 --- a/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs +++ b/backend/UniVerse.Api.Tests/Authorization/EndpointAuthorizationTests.cs @@ -121,6 +121,9 @@ public class EndpointAuthorizationTests : IClassFixture ReplaceWithSubstitute(services, CreateUserServiceStub()); ReplaceWithSubstitute(services, CreateLectureServiceStub()); ReplaceWithSubstitute(services, CreateReviewServiceStub()); + ReplaceWithSubstitute(services, CreateReviewPromptServiceStub()); ReplaceWithSubstitute(services, CreateCourseServiceStub()); ReplaceWithSubstitute(services, CreateTagServiceStub()); ReplaceWithSubstitute(services, CreateLocationServiceStub()); @@ -226,6 +227,19 @@ public class ApiWebApplicationFactory : WebApplicationFactory return stub; } + private static IReviewPromptService CreateReviewPromptServiceStub() + { + var stub = Substitute.For(); + var promptDto = new ReviewPromptDto( + "Analyze {lectureContext}. Review: {reviewText}", + DateTime.UtcNow); + + stub.GetAsync().Returns(promptDto); + stub.UpdateAsync(Arg.Any()).Returns(callInfo => + new ReviewPromptDto(callInfo.Arg().Prompt, DateTime.UtcNow)); + return stub; + } + private static ICourseService CreateCourseServiceStub() { var stub = Substitute.For(); diff --git a/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs new file mode 100644 index 0000000..5a1a76a --- /dev/null +++ b/backend/UniVerse.Api.Tests/Reviews/ReviewPromptServiceTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Prompts; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; +using UniVerse.Infrastructure.ExternalServices; +using UniVerse.Infrastructure.Services; +using Xunit; + +namespace UniVerse.Api.Tests.Reviews; + +public class ReviewPromptServiceTests +{ + [Fact] + public async Task GetAsync_ReturnsDefaultPrompt_WhenSettingDoesNotExist() + { + await using var db = CreateDbContext(); + var service = new ReviewPromptService(db); + + var result = await service.GetAsync(); + + Assert.Equal(ReviewPromptTemplate.Default, result.Prompt); + Assert.Null(result.UpdatedAt); + } + + [Fact] + public async Task UpdateAsync_UpsertsSingletonPrompt() + { + await using var db = CreateDbContext(); + var service = new ReviewPromptService(db); + + await service.UpdateAsync(new UpdateReviewPromptRequest("First {lectureContext} {reviewText}")); + var result = await service.UpdateAsync(new UpdateReviewPromptRequest("Second {lectureContext} {reviewText}")); + + Assert.Equal("Second {lectureContext} {reviewText}", result.Prompt); + Assert.NotNull(result.UpdatedAt); + Assert.Equal(1, await db.ReviewPromptSettings.CountAsync()); + Assert.Equal("Second {lectureContext} {reviewText}", (await service.GetAsync()).Prompt); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Prompt without placeholders")] + [InlineData("Only lecture {lectureContext}")] + [InlineData("Only review {reviewText}")] + public async Task UpdateAsync_RejectsInvalidPrompt(string prompt) + { + await using var db = CreateDbContext(); + var service = new ReviewPromptService(db); + + await Assert.ThrowsAsync(() => + service.UpdateAsync(new UpdateReviewPromptRequest(prompt))); + } + + [Fact] + public async Task AnalyzeReviewAsync_RendersCustomPrompt() + { + var handler = new CapturingHandler(); + var http = new HttpClient(handler) + { + BaseAddress = new Uri("https://llm.test/") + }; + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Llm:Model"] = "test-model", + ["Llm:ApiKey"] = "test-key" + }) + .Build(); + var promptService = Substitute.For(); + promptService.GetAsync().Returns(new ReviewPromptDto( + "Custom prompt. Context: {lectureContext}. Text: {reviewText}", + DateTime.UtcNow)); + var client = new LlmClient(http, config, promptService, NullLogger.Instance); + + await client.AnalyzeReviewAsync("Very useful review", "Lecture: Algebra"); + + Assert.NotNull(handler.RequestBody); + using var requestJson = JsonDocument.Parse(handler.RequestBody!); + var content = requestJson.RootElement + .GetProperty("messages")[0] + .GetProperty("content") + .GetString(); + + Assert.Contains("Custom prompt", content); + Assert.Contains("Lecture: Algebra", content); + Assert.Contains("Very useful review", content); + Assert.DoesNotContain(ReviewPromptTemplate.LectureContextPlaceholder, content); + Assert.DoesNotContain(ReviewPromptTemplate.ReviewTextPlaceholder, content); + } + + private static AppDbContext CreateDbContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}") + .Options; + return new AppDbContext(options); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public string? RequestBody { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RequestBody = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + + const string responsePayload = """ + { + "choices": [ + { + "message": { + "content": "{\"qualityScore\":0.8,\"sentiment\":\"Positive\",\"tags\":[\"practice\"],\"isInformative\":true}" + } + } + ] + } + """; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responsePayload, Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/backend/UniVerse.Api/Controllers/ReviewsController.cs b/backend/UniVerse.Api/Controllers/ReviewsController.cs index 53dc79d..b01aef7 100644 --- a/backend/UniVerse.Api/Controllers/ReviewsController.cs +++ b/backend/UniVerse.Api/Controllers/ReviewsController.cs @@ -15,8 +15,13 @@ namespace UniVerse.Api.Controllers; public class ReviewsController : ControllerBase { private readonly IReviewService _reviews; + private readonly IReviewPromptService _reviewPrompts; - public ReviewsController(IReviewService reviews) => _reviews = reviews; + public ReviewsController(IReviewService reviews, IReviewPromptService reviewPrompts) + { + _reviews = reviews; + _reviewPrompts = reviewPrompts; + } private int CurrentUserId => int.Parse( User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); @@ -57,6 +62,35 @@ public class ReviewsController : ControllerBase public async Task List([FromQuery] PaginationRequest pagination) => Ok(await _reviews.GetAllAsync(pagination)); + /// Получить текущий промпт LLM-анализа отзывов. + /// Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт. + /// Текущий шаблон промпта. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpGet("llm-prompt")] + [ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> GetLlmPrompt() => + Ok(await _reviewPrompts.GetAsync()); + + /// Обновить промпт LLM-анализа отзывов. + /// Только Admin. Промпт применяется к следующим анализам и ручным повторам. + /// Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}. + /// Сохранённый шаблон промпта. + /// Промпт пустой или не содержит обязательные плейсхолдеры. + /// Требуется аутентификация. + /// Требуется роль Admin. + [Authorize(Roles = "Admin")] + [HttpPut("llm-prompt")] + [ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) => + Ok(await _reviewPrompts.UpdateAsync(request)); + /// Получить отзыв по ID. /// Только Admin или Teacher. /// ID отзыва. diff --git a/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs index bd13b7e..17c490f 100644 --- a/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs +++ b/backend/UniVerse.Api/Middleware/ExceptionHandlingMiddleware.cs @@ -24,6 +24,7 @@ public class ExceptionHandlingMiddleware { var (statusCode, title) = exception switch { + BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"), NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"), ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"), ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"), diff --git a/backend/UniVerse.Api/Program.cs b/backend/UniVerse.Api/Program.cs index 741149a..f66c10d 100644 --- a/backend/UniVerse.Api/Program.cs +++ b/backend/UniVerse.Api/Program.cs @@ -89,6 +89,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/UniVerse.Api/openapi.json b/backend/UniVerse.Api/openapi.json index a8071e5..b6c2186 100644 --- a/backend/UniVerse.Api/openapi.json +++ b/backend/UniVerse.Api/openapi.json @@ -2587,6 +2587,126 @@ ] } }, + "/api/v1/reviews/llm-prompt": { + "get": { + "tags": [ + "Reviews" + ], + "summary": "Получить текущий промпт LLM-анализа отзывов.", + "description": "Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.\n\n**Required roles:** Admin", + "responses": { + "200": { + "description": "Текущий шаблон промпта.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPromptDto" + } + } + } + }, + "401": { + "description": "Требуется аутентификация.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "403": { + "description": "Требуется роль Admin.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { + "Bearer": [ ] + } + ] + }, + "put": { + "tags": [ + "Reviews" + ], + "summary": "Обновить промпт LLM-анализа отзывов.", + "description": "Только Admin. Промпт применяется к следующим анализам и ручным повторам.\n\n**Required roles:** Admin", + "requestBody": { + "description": "Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateReviewPromptRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Сохранённый шаблон промпта.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewPromptDto" + } + } + } + }, + "400": { + "description": "Промпт пустой или не содержит обязательные плейсхолдеры.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "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}": { "get": { "tags": [ @@ -5506,6 +5626,21 @@ ], "type": "string" }, + "ReviewPromptDto": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + }, "ReviewRating": { "enum": [ "Like", @@ -5853,6 +5988,16 @@ }, "additionalProperties": false }, + "UpdateReviewPromptRequest": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, "UpdateReviewRequest": { "type": "object", "properties": { diff --git a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs index 416ee41..6ddc65a 100644 --- a/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs +++ b/backend/UniVerse.Application/DTOs/Reviews/ReviewDtos.cs @@ -21,3 +21,7 @@ public record ReviewDto( public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text); public record UpdateReviewRequest(ReviewRating Rating, string? Text); + +public record ReviewPromptDto(string Prompt, DateTime? UpdatedAt); + +public record UpdateReviewPromptRequest(string Prompt); diff --git a/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs b/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs new file mode 100644 index 0000000..77deb86 --- /dev/null +++ b/backend/UniVerse.Application/Interfaces/IReviewPromptService.cs @@ -0,0 +1,9 @@ +using UniVerse.Application.DTOs.Reviews; + +namespace UniVerse.Application.Interfaces; + +public interface IReviewPromptService +{ + Task GetAsync(); + Task UpdateAsync(UpdateReviewPromptRequest request); +} diff --git a/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs new file mode 100644 index 0000000..db01367 --- /dev/null +++ b/backend/UniVerse.Application/Prompts/ReviewPromptTemplate.cs @@ -0,0 +1,27 @@ +namespace UniVerse.Application.Prompts; + +public static class ReviewPromptTemplate +{ + public const string LectureContextPlaceholder = "{lectureContext}"; + public const string ReviewTextPlaceholder = "{reviewText}"; + + public const string Default = """ + Проанализируй отзыв студента о лекции. Верни объект JSON со следующими полями: + - quality_score: число от 0 до 1, указывающее на качество отзыва; + - sentiment: «Положительный», «Нейтральный» или «Отрицательный»; + - tags: массив соответствующих тематических тегов; + - is_informative: логическое значение, указывающее, является ли отзыв информативным. + + Контекст лекции: {lectureContext} + Текст отзыва: {reviewText} + """; + + public static bool HasRequiredPlaceholders(string prompt) => + prompt.Contains(LectureContextPlaceholder, StringComparison.Ordinal) && + prompt.Contains(ReviewTextPlaceholder, StringComparison.Ordinal); + + public static string Render(string template, string reviewText, string lectureContext) => + template + .Replace(LectureContextPlaceholder, lectureContext) + .Replace(ReviewTextPlaceholder, reviewText); +} diff --git a/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs b/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs new file mode 100644 index 0000000..070cbfe --- /dev/null +++ b/backend/UniVerse.Domain/Entities/ReviewPromptSetting.cs @@ -0,0 +1,11 @@ +namespace UniVerse.Domain.Entities; + +public class ReviewPromptSetting +{ + public const int SingletonId = 1; + + public int Id { get; set; } = SingletonId; + public string Prompt { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/UniVerse.Domain/Exceptions/BadRequestException.cs b/backend/UniVerse.Domain/Exceptions/BadRequestException.cs new file mode 100644 index 0000000..bfda608 --- /dev/null +++ b/backend/UniVerse.Domain/Exceptions/BadRequestException.cs @@ -0,0 +1,8 @@ +namespace UniVerse.Domain.Exceptions; + +public class BadRequestException : Exception +{ + public BadRequestException(string message) : base(message) + { + } +} diff --git a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs index d580960..f6ba558 100644 --- a/backend/UniVerse.Infrastructure/Data/AppDbContext.cs +++ b/backend/UniVerse.Infrastructure/Data/AppDbContext.cs @@ -20,6 +20,7 @@ public class AppDbContext : DbContext public DbSet CourseTags { get; set; } = null!; public DbSet LectureEnrollments { get; set; } = null!; public DbSet Reviews { get; set; } = null!; + public DbSet ReviewPromptSettings { get; set; } = null!; public DbSet Achievements { get; set; } = null!; public DbSet UserAchievements { get; set; } = null!; public DbSet CoinTransactions { get; set; } = null!; diff --git a/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs new file mode 100644 index 0000000..797a2a5 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Data/Configurations/ReviewPromptSettingConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using UniVerse.Domain.Entities; + +namespace UniVerse.Infrastructure.Data.Configurations; + +public class ReviewPromptSettingConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("review_prompt_settings"); + + builder.HasKey(setting => setting.Id); + builder.Property(setting => setting.Id) + .HasColumnName("id") + .ValueGeneratedNever(); + builder.Property(setting => setting.Prompt) + .HasColumnName("prompt") + .IsRequired(); + builder.Property(setting => setting.CreatedAt) + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + builder.Property(setting => setting.UpdatedAt) + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + } +} diff --git a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs index 903dc80..6341fde 100644 --- a/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs +++ b/backend/UniVerse.Infrastructure/ExternalServices/LlmClient.cs @@ -1,9 +1,9 @@ using System.Net.Http.Json; -using System.Text; using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using UniVerse.Application.Interfaces; +using UniVerse.Application.Prompts; namespace UniVerse.Infrastructure.ExternalServices; @@ -11,25 +11,25 @@ public class LlmClient : ILlmClient { private readonly HttpClient _http; private readonly IConfiguration _config; + private readonly IReviewPromptService _reviewPrompts; private readonly ILogger _logger; - public LlmClient(HttpClient http, IConfiguration config, ILogger logger) + public LlmClient( + HttpClient http, + IConfiguration config, + IReviewPromptService reviewPrompts, + ILogger logger) { - _http = http; _config = config; _logger = logger; + _http = http; + _config = config; + _reviewPrompts = reviewPrompts; + _logger = logger; } public async Task AnalyzeReviewAsync(string reviewText, string lectureContext) { - var prompt = $""" - Analyze the following student review of a lecture. Return a JSON object with: - - quality_score: float 0-1 indicating review quality - - sentiment: "Positive", "Neutral", or "Negative" - - tags: array of relevant topic tags - - is_informative: boolean indicating if the review is informative - - Lecture context: {lectureContext} - Review text: {reviewText} - """; + var promptSetting = await _reviewPrompts.GetAsync(); + var prompt = ReviewPromptTemplate.Render(promptSetting.Prompt, reviewText, lectureContext); var request = new { diff --git a/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs new file mode 100644 index 0000000..98d18a1 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.Designer.cs @@ -0,0 +1,1135 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using UniVerse.Infrastructure.Data; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260521170452_ReviewPromptSettings")] + partial class ReviewPromptSettings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "coin_transaction_type", "coin_transaction_type", new[] { "review_reward", "achievement_reward", "attendance_reward", "admin_adjustment" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_llm_status", "review_llm_status", new[] { "pending", "analyzed", "rejected" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "review_sentiment", "review_sentiment", new[] { "positive", "neutral", "negative" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "tag_type", "tag_type", new[] { "institute", "faculty", "subject", "organization", "topic", "other" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "user_role", "user_role", new[] { "student", "teacher", "admin" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CoinReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coin_reward"); + + b.Property("Condition") + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("icon_url"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("XpReward") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp_reward"); + + b.HasKey("Id"); + + b.ToTable("achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("Amount") + .HasColumnType("integer") + .HasColumnName("amount"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("description"); + + b.Property("ReviewId") + .HasColumnType("integer") + .HasColumnName("review_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("AchievementId"); + + b.HasIndex("ReviewId"); + + b.HasIndex("UserId"); + + b.ToTable("coin_transactions", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("IsSynced") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_synced"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("courses", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("TagId") + .HasColumnType("integer") + .HasColumnName("tag_id"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("CourseId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("CourseId", "TagId") + .IsUnique(); + + b.ToTable("course_tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("integer") + .HasColumnName("course_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ends_at"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Format") + .HasColumnType("integer") + .HasColumnName("format"); + + b.Property("IsOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_open"); + + b.Property("LocationId") + .HasColumnType("integer") + .HasColumnName("location_id"); + + b.Property("MaxEnrollments") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("max_enrollments"); + + b.Property("OnlineUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("online_url"); + + b.Property("StartsAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("starts_at"); + + b.Property("TeacherId") + .HasColumnType("integer") + .HasColumnName("teacher_id"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.HasIndex("LocationId"); + + b.HasIndex("StartsAt"); + + b.HasIndex("TeacherId"); + + b.ToTable("lectures", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Attended") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("attended"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("LectureId", "UserId"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("lecture_enrollments", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LevelThreshold", b => + { + b.Property("Level") + .HasColumnType("integer") + .HasColumnName("level"); + + b.Property("RequiredXp") + .HasColumnType("integer") + .HasColumnName("required_xp"); + + b.HasKey("Level"); + + b.HasIndex("RequiredXp") + .IsUnique(); + + b.ToTable("level_thresholds", null, t => + { + t.HasCheckConstraint("CK_level_thresholds_level_positive", "level > 0"); + + t.HasCheckConstraint("CK_level_thresholds_required_xp_non_negative", "required_xp >= 0"); + }); + + b.HasData( + new + { + Level = 1, + RequiredXp = 0 + }, + new + { + Level = 2, + RequiredXp = 100 + }, + new + { + Level = 3, + RequiredXp = 300 + }, + new + { + Level = 4, + RequiredXp = 600 + }, + new + { + Level = 5, + RequiredXp = 1000 + }, + new + { + Level = 6, + RequiredXp = 1500 + }, + new + { + Level = 7, + RequiredXp = 2500 + }, + new + { + Level = 8, + RequiredXp = 4000 + }); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("address"); + + b.Property("Building") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("building"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExternalId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("external_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("Room") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("room"); + + b.HasKey("Id"); + + b.HasIndex("ExternalId") + .IsUnique() + .HasFilter("external_id IS NOT NULL"); + + b.ToTable("locations", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("token"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("refresh_tokens", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsInformative") + .HasColumnType("boolean") + .HasColumnName("is_informative"); + + b.Property("LectureId") + .HasColumnType("integer") + .HasColumnName("lecture_id"); + + b.Property("LlmStatus") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("llm_status"); + + b.PrimitiveCollection("LlmTags") + .HasColumnType("text[]") + .HasColumnName("llm_tags"); + + b.Property("QualityScore") + .HasColumnType("double precision") + .HasColumnName("quality_score"); + + b.Property("Rating") + .HasColumnType("integer") + .HasColumnName("rating"); + + b.Property("Sentiment") + .HasColumnType("integer") + .HasColumnName("sentiment"); + + b.Property("Text") + .HasColumnType("text") + .HasColumnName("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("LlmStatus"); + + b.HasIndex("UserId"); + + b.HasIndex("LectureId", "UserId") + .IsUnique(); + + b.ToTable("reviews", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.ReviewPromptSetting", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.ToTable("review_prompt_settings", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("EnrollmentYear") + .HasColumnType("integer") + .HasColumnName("enrollment_year"); + + b.Property("Faculty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("faculty"); + + b.Property("GroupName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("group_name"); + + b.Property("Specialty") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("specialty"); + + b.Property("StudentId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("student_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("student_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("name"); + + b.Property("ParentId") + .HasColumnType("integer") + .HasColumnName("parent_id"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("tags", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("Department") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("department"); + + b.Property("ModeusId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("modeus_id"); + + b.Property("Title") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("teacher_profiles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("avatar_url"); + + b.Property("Coins") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("coins"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("display_name"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("email"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("is_active"); + + b.Property("MicrosoftId") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("microsoft_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Xp") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("xp"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("MicrosoftId") + .IsUnique() + .HasFilter("microsoft_id IS NOT NULL"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("AchievementId") + .HasColumnType("integer") + .HasColumnName("achievement_id"); + + b.Property("AwardedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.HasKey("UserId", "AchievementId"); + + b.HasIndex("AchievementId"); + + b.HasIndex("UserId", "AchievementId") + .IsUnique(); + + b.ToTable("user_achievements", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)") + .HasColumnName("body"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_read"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("title"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("type"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "CreatedAt"); + + b.ToTable("user_notifications", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.HasKey("UserId", "Role"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CoinTransaction", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany() + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.Review", "Review") + .WithMany("CoinTransactions") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("CoinTransactions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("Review"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.CourseTag", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("CourseTags") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Tag", "Tag") + .WithMany("CourseTags") + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.HasOne("UniVerse.Domain.Entities.Course", "Course") + .WithMany("Lectures") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.Location", "Location") + .WithMany("Lectures") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("UniVerse.Domain.Entities.User", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Course"); + + b.Navigation("Location"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.LectureEnrollment", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Enrollments") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Enrollments") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.RefreshToken", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.HasOne("UniVerse.Domain.Entities.Lecture", "Lecture") + .WithMany("Reviews") + .HasForeignKey("LectureId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Lecture"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("StudentProfile") + .HasForeignKey("UniVerse.Domain.Entities.StudentProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.HasOne("UniVerse.Domain.Entities.Tag", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.TeacherProfile", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithOne("TeacherProfile") + .HasForeignKey("UniVerse.Domain.Entities.TeacherProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserAchievement", b => + { + b.HasOne("UniVerse.Domain.Entities.Achievement", "Achievement") + .WithMany("UserAchievements") + .HasForeignKey("AchievementId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("UserAchievements") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Achievement"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserNotification", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.UserRoleAssignment", b => + { + b.HasOne("UniVerse.Domain.Entities.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Achievement", b => + { + b.Navigation("UserAchievements"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Course", b => + { + b.Navigation("CourseTags"); + + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Lecture", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Location", b => + { + b.Navigation("Lectures"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Review", b => + { + b.Navigation("CoinTransactions"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.Tag", b => + { + b.Navigation("Children"); + + b.Navigation("CourseTags"); + }); + + modelBuilder.Entity("UniVerse.Domain.Entities.User", b => + { + b.Navigation("CoinTransactions"); + + b.Navigation("Enrollments"); + + b.Navigation("Notifications"); + + b.Navigation("RefreshTokens"); + + b.Navigation("Reviews"); + + b.Navigation("Roles"); + + b.Navigation("StudentProfile"); + + b.Navigation("TeacherProfile"); + + b.Navigation("UserAchievements"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs new file mode 100644 index 0000000..656a525 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Migrations/20260521170452_ReviewPromptSettings.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace UniVerse.Infrastructure.Migrations +{ + /// + public partial class ReviewPromptSettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "review_prompt_settings", + columns: table => new + { + id = table.Column(type: "integer", nullable: false), + prompt = table.Column(type: "text", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"), + updated_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()") + }, + constraints: table => + { + table.PrimaryKey("PK_review_prompt_settings", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "review_prompt_settings"); + } + } +} diff --git a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index a8175b3..42ae979 100644 --- a/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/UniVerse.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -563,6 +563,34 @@ namespace UniVerse.Infrastructure.Migrations b.ToTable("reviews", (string)null); }); + modelBuilder.Entity("UniVerse.Domain.Entities.ReviewPromptSetting", b => + { + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("NOW()"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text") + .HasColumnName("prompt"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("NOW()"); + + b.HasKey("Id"); + + b.ToTable("review_prompt_settings", (string)null); + }); + modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b => { b.Property("Id") diff --git a/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs b/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs new file mode 100644 index 0000000..a7165c2 --- /dev/null +++ b/backend/UniVerse.Infrastructure/Services/ReviewPromptService.cs @@ -0,0 +1,70 @@ +using Microsoft.EntityFrameworkCore; +using UniVerse.Application.DTOs.Reviews; +using UniVerse.Application.Interfaces; +using UniVerse.Application.Prompts; +using UniVerse.Domain.Entities; +using UniVerse.Domain.Exceptions; +using UniVerse.Infrastructure.Data; + +namespace UniVerse.Infrastructure.Services; + +public class ReviewPromptService : IReviewPromptService +{ + private readonly AppDbContext _db; + + public ReviewPromptService(AppDbContext db) + { + _db = db; + } + + public async Task GetAsync() + { + var setting = await _db.ReviewPromptSettings + .AsNoTracking() + .FirstOrDefaultAsync(s => s.Id == ReviewPromptSetting.SingletonId); + + return setting is null + ? new ReviewPromptDto(ReviewPromptTemplate.Default, null) + : new ReviewPromptDto(setting.Prompt, setting.UpdatedAt); + } + + public async Task UpdateAsync(UpdateReviewPromptRequest request) + { + ValidatePrompt(request.Prompt); + + var now = DateTime.UtcNow; + var setting = await _db.ReviewPromptSettings + .FirstOrDefaultAsync(s => s.Id == ReviewPromptSetting.SingletonId); + + if (setting is null) + { + setting = new ReviewPromptSetting + { + Id = ReviewPromptSetting.SingletonId, + Prompt = request.Prompt, + CreatedAt = now, + UpdatedAt = now + }; + _db.ReviewPromptSettings.Add(setting); + } + else + { + setting.Prompt = request.Prompt; + setting.UpdatedAt = now; + } + + await _db.SaveChangesAsync(); + + return new ReviewPromptDto(setting.Prompt, setting.UpdatedAt); + } + + private static void ValidatePrompt(string prompt) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new BadRequestException("Prompt must not be empty."); + + if (!ReviewPromptTemplate.HasRequiredPlaceholders(prompt)) + throw new BadRequestException( + $"Prompt must contain {ReviewPromptTemplate.LectureContextPlaceholder} and {ReviewPromptTemplate.ReviewTextPlaceholder} placeholders."); + } +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b605b76..972ad2c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -11,10 +11,12 @@ import type { PagedResult, ReviewDto, ReviewQuery, + ReviewPromptDto, SyncResultDto, SyncScheduleRequest, SyncStatusDto, TagDto, + UpdateReviewPromptRequest, UserAchievementDto, CurrentUserDto, UserDto, @@ -183,6 +185,12 @@ export const reviewsApi = { method: 'POST', body: JSON.stringify({ lectureId: Number(lectureId), rating, text }), }), + getPrompt: () => apiRequest('/reviews/llm-prompt'), + updatePrompt: (payload: UpdateReviewPromptRequest) => + apiRequest('/reviews/llm-prompt', { + method: 'PUT', + body: JSON.stringify(payload), + }), listPage: listReviewsPage, async list(query: ReviewQuery = { PageSize: 100 }) { return (await listReviewsPage(query)).items diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 66e7664..6126947 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -136,6 +136,15 @@ export interface ReviewDto { createdAt: string } +export interface ReviewPromptDto { + prompt: string + updatedAt?: string | null +} + +export interface UpdateReviewPromptRequest { + prompt: string +} + export interface AchievementDto { id: number name?: string | null diff --git a/frontend/src/views/admin/AdminReviewsView.vue b/frontend/src/views/admin/AdminReviewsView.vue index 32675bb..c241f72 100644 --- a/frontend/src/views/admin/AdminReviewsView.vue +++ b/frontend/src/views/admin/AdminReviewsView.vue @@ -41,6 +41,13 @@ const totalCount = ref(0) const totalPages = ref(0) const reanalyzingId = ref(null) const error = ref('') +const promptText = ref('') +const savedPromptText = ref('') +const promptUpdatedAt = ref(null) +const promptLoading = ref(false) +const promptSaving = ref(false) +const promptError = ref('') +const promptSuccess = ref('') const rows = computed(() => reviews.value.map((review) => ({ @@ -73,12 +80,63 @@ const pageStart = computed(() => const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value)) const canGoPrev = computed(() => page.value > 1) const canGoNext = computed(() => page.value < totalPages.value) +const promptStatusLabel = computed(() => { + if (promptLoading.value) return 'Загрузка...' + if (!promptUpdatedAt.value) return 'Базовый промпт' + return `Обновлён ${new Date(promptUpdatedAt.value).toLocaleString('ru-RU')}` +}) +const canSavePrompt = computed( + () => + !promptLoading.value && + !promptSaving.value && + promptText.value.trim().length > 0 && + promptText.value !== savedPromptText.value, +) function formatQuality(value: number | null | undefined) { if (value === null || value === undefined) return '—' return Number(value).toFixed(2) } +async function fetchPrompt() { + promptLoading.value = true + promptError.value = '' + promptSuccess.value = '' + try { + const result = await reviewsApi.getPrompt() + promptText.value = result.prompt + savedPromptText.value = result.prompt + promptUpdatedAt.value = result.updatedAt ?? null + } catch (err) { + promptError.value = err instanceof Error ? err.message : 'Не удалось загрузить промпт.' + } finally { + promptLoading.value = false + } +} + +async function savePrompt() { + promptError.value = '' + promptSuccess.value = '' + + if (!promptText.value.trim()) { + promptError.value = 'Промпт не должен быть пустым.' + return + } + + promptSaving.value = true + try { + const result = await reviewsApi.updatePrompt({ prompt: promptText.value }) + promptText.value = result.prompt + savedPromptText.value = result.prompt + promptUpdatedAt.value = result.updatedAt ?? null + promptSuccess.value = 'Промпт сохранён.' + } catch (err) { + promptError.value = err instanceof Error ? err.message : 'Не удалось сохранить промпт.' + } finally { + promptSaving.value = false + } +} + async function fetchReviews() { loading.value = true error.value = '' @@ -115,7 +173,10 @@ async function reanalyze(id: number) { } } -onMounted(fetchReviews) +onMounted(() => { + void fetchPrompt() + void fetchReviews() +})