feat: добавил изменение промта для админа
Backend CI / build-and-test (push) Failing after 11m26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Failing after 14m2s
Frontend CI / build-and-check (push) Failing after 19m55s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Failing after 14m7s
🚀 Create and publish a Docker image / Build & publish backend image (push) Failing after 14m59s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Failing after 15m0s

This commit is contained in:
2026-05-21 21:58:33 +03:00
parent 27a2811806
commit 935e4ed37a
22 changed files with 1880 additions and 15 deletions
@@ -121,6 +121,9 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews/llm-prompt GET [Admin]", "GET", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/llm-prompt PUT [Admin]", "PUT", "api/v1/reviews/llm-prompt","Admin", forbidden: ["Student", "Teacher"],
body: """{"prompt":"Analyze {lectureContext}. Review: {reviewText}"}""");
yield return E("reviews/pending GET [Admin]", "GET", "api/v1/reviews/pending","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
@@ -89,6 +89,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
ReplaceWithSubstitute<IReviewPromptService>(services, CreateReviewPromptServiceStub());
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
@@ -226,6 +227,19 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
return stub;
}
private static IReviewPromptService CreateReviewPromptServiceStub()
{
var stub = Substitute.For<IReviewPromptService>();
var promptDto = new ReviewPromptDto(
"Analyze {lectureContext}. Review: {reviewText}",
DateTime.UtcNow);
stub.GetAsync().Returns(promptDto);
stub.UpdateAsync(Arg.Any<UpdateReviewPromptRequest>()).Returns(callInfo =>
new ReviewPromptDto(callInfo.Arg<UpdateReviewPromptRequest>().Prompt, DateTime.UtcNow));
return stub;
}
private static ICourseService CreateCourseServiceStub()
{
var stub = Substitute.For<ICourseService>();
@@ -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<BadRequestException>(() =>
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<string, string?>
{
["Llm:Model"] = "test-model",
["Llm:ApiKey"] = "test-key"
})
.Build();
var promptService = Substitute.For<IReviewPromptService>();
promptService.GetAsync().Returns(new ReviewPromptDto(
"Custom prompt. Context: {lectureContext}. Text: {reviewText}",
DateTime.UtcNow));
var client = new LlmClient(http, config, promptService, NullLogger<LlmClient>.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<AppDbContext>()
.UseInMemoryDatabase($"ReviewPromptServiceTests_{Guid.NewGuid()}")
.Options;
return new AppDbContext(options);
}
private sealed class CapturingHandler : HttpMessageHandler
{
public string? RequestBody { get; private set; }
protected override async Task<HttpResponseMessage> 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")
};
}
}
}
@@ -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<ActionResult> List([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetAllAsync(pagination));
/// <summary>Получить текущий промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Если настройка ещё не сохранена, возвращает базовый промпт.</remarks>
/// <response code="200">Текущий шаблон промпта.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpGet("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> GetLlmPrompt() =>
Ok(await _reviewPrompts.GetAsync());
/// <summary>Обновить промпт LLM-анализа отзывов.</summary>
/// <remarks>Только Admin. Промпт применяется к следующим анализам и ручным повторам.</remarks>
/// <param name="request">Новый шаблон с плейсхолдерами {lectureContext} и {reviewText}.</param>
/// <response code="200">Сохранённый шаблон промпта.</response>
/// <response code="400">Промпт пустой или не содержит обязательные плейсхолдеры.</response>
/// <response code="401">Требуется аутентификация.</response>
/// <response code="403">Требуется роль Admin.</response>
[Authorize(Roles = "Admin")]
[HttpPut("llm-prompt")]
[ProducesResponseType(typeof(ReviewPromptDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<ActionResult<ReviewPromptDto>> UpdateLlmPrompt([FromBody] UpdateReviewPromptRequest request) =>
Ok(await _reviewPrompts.UpdateAsync(request));
/// <summary>Получить отзыв по ID.</summary>
/// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID отзыва.</param>
@@ -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"),
+1
View File
@@ -89,6 +89,7 @@ builder.Services.AddScoped<ILocationService, LocationService>();
builder.Services.AddScoped<ICourseService, CourseService>();
builder.Services.AddScoped<ILectureService, LectureService>();
builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>();
+145
View File
@@ -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": {
@@ -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);
@@ -0,0 +1,9 @@
using UniVerse.Application.DTOs.Reviews;
namespace UniVerse.Application.Interfaces;
public interface IReviewPromptService
{
Task<ReviewPromptDto> GetAsync();
Task<ReviewPromptDto> UpdateAsync(UpdateReviewPromptRequest request);
}
@@ -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);
}
@@ -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;
}
@@ -0,0 +1,8 @@
namespace UniVerse.Domain.Exceptions;
public class BadRequestException : Exception
{
public BadRequestException(string message) : base(message)
{
}
}
@@ -20,6 +20,7 @@ public class AppDbContext : DbContext
public DbSet<CourseTag> CourseTags { get; set; } = null!;
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
public DbSet<Review> Reviews { get; set; } = null!;
public DbSet<ReviewPromptSetting> ReviewPromptSettings { get; set; } = null!;
public DbSet<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { get; set; } = null!;
@@ -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<ReviewPromptSetting>
{
public void Configure(EntityTypeBuilder<ReviewPromptSetting> 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()");
}
}
@@ -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<LlmClient> _logger;
public LlmClient(HttpClient http, IConfiguration config, ILogger<LlmClient> logger)
public LlmClient(
HttpClient http,
IConfiguration config,
IReviewPromptService reviewPrompts,
ILogger<LlmClient> logger)
{
_http = http; _config = config; _logger = logger;
_http = http;
_config = config;
_reviewPrompts = reviewPrompts;
_logger = logger;
}
public async Task<LlmReviewAnalysis> 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
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace UniVerse.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ReviewPromptSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "review_prompt_settings",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false),
prompt = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()"),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false, defaultValueSql: "NOW()")
},
constraints: table =>
{
table.PrimaryKey("PK_review_prompt_settings", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "review_prompt_settings");
}
}
}
@@ -563,6 +563,34 @@ namespace UniVerse.Infrastructure.Migrations
b.ToTable("reviews", (string)null);
});
modelBuilder.Entity("UniVerse.Domain.Entities.ReviewPromptSetting", b =>
{
b.Property<int>("Id")
.HasColumnType("integer")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at")
.HasDefaultValueSql("NOW()");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("text")
.HasColumnName("prompt");
b.Property<DateTime>("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<int>("Id")
@@ -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<ReviewPromptDto> 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<ReviewPromptDto> 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.");
}
}