Dev #11

Merged
serega404 merged 87 commits from dev into main 2026-05-25 03:22:55 +03:00
22 changed files with 1880 additions and 15 deletions
Showing only changes of commit 935e4ed37a - Show all commits
@@ -121,6 +121,9 @@ public class EndpointAuthorizationTests : IClassFixture<ApiWebApplicationFactory
body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}"""); body: """{"lectureId":1,"rating":"Like","text":"Great lecture!"}""");
// ── Reviews — Admin only ────────────────────────────────────────────── // ── Reviews — Admin only ──────────────────────────────────────────────
yield return E("reviews/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/pending GET [Admin]", "GET", "api/v1/reviews/pending","Admin", forbidden: ["Student", "Teacher"]);
yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]); yield return E("reviews/{id}/reanalyze POST [Admin]","POST", "api/v1/reviews/1/reanalyze","Admin",forbidden: ["Student", "Teacher"]);
@@ -89,6 +89,7 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub()); ReplaceWithSubstitute<IUserService>(services, CreateUserServiceStub());
ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub()); ReplaceWithSubstitute<ILectureService>(services, CreateLectureServiceStub());
ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub()); ReplaceWithSubstitute<IReviewService>(services, CreateReviewServiceStub());
ReplaceWithSubstitute<IReviewPromptService>(services, CreateReviewPromptServiceStub());
ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub()); ReplaceWithSubstitute<ICourseService>(services, CreateCourseServiceStub());
ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub()); ReplaceWithSubstitute<ITagService>(services, CreateTagServiceStub());
ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub()); ReplaceWithSubstitute<ILocationService>(services, CreateLocationServiceStub());
@@ -226,6 +227,19 @@ public class ApiWebApplicationFactory : WebApplicationFactory<Program>
return stub; 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() private static ICourseService CreateCourseServiceStub()
{ {
var stub = Substitute.For<ICourseService>(); 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 public class ReviewsController : ControllerBase
{ {
private readonly IReviewService _reviews; 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( private int CurrentUserId => int.Parse(
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0"); User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? "0");
@@ -57,6 +62,35 @@ public class ReviewsController : ControllerBase
public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) => public async Task<ActionResult> List([FromQuery] PaginationRequest pagination) =>
Ok(await _reviews.GetAllAsync(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> /// <summary>Получить отзыв по ID.</summary>
/// <remarks>Только Admin или Teacher.</remarks> /// <remarks>Только Admin или Teacher.</remarks>
/// <param name="id">ID отзыва.</param> /// <param name="id">ID отзыва.</param>
@@ -24,6 +24,7 @@ public class ExceptionHandlingMiddleware
{ {
var (statusCode, title) = exception switch var (statusCode, title) = exception switch
{ {
BadRequestException => ((int)HttpStatusCode.BadRequest, "Bad Request"),
NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"), NotFoundException => ((int)HttpStatusCode.NotFound, "Not Found"),
ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"), ForbiddenException => ((int)HttpStatusCode.Forbidden, "Forbidden"),
ConflictException => ((int)HttpStatusCode.Conflict, "Conflict"), 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<ICourseService, CourseService>();
builder.Services.AddScoped<ILectureService, LectureService>(); builder.Services.AddScoped<ILectureService, LectureService>();
builder.Services.AddScoped<IReviewService, ReviewService>(); builder.Services.AddScoped<IReviewService, ReviewService>();
builder.Services.AddScoped<IReviewPromptService, ReviewPromptService>();
builder.Services.AddScoped<IGamificationService, GamificationService>(); builder.Services.AddScoped<IGamificationService, GamificationService>();
builder.Services.AddScoped<IAchievementService, AchievementService>(); builder.Services.AddScoped<IAchievementService, AchievementService>();
builder.Services.AddScoped<ILlmAnalysisService, LlmAnalysisService>(); 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}": { "/api/v1/reviews/{id}": {
"get": { "get": {
"tags": [ "tags": [
@@ -5506,6 +5626,21 @@
], ],
"type": "string" "type": "string"
}, },
"ReviewPromptDto": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"nullable": true
},
"updatedAt": {
"type": "string",
"format": "date-time",
"nullable": true
}
},
"additionalProperties": false
},
"ReviewRating": { "ReviewRating": {
"enum": [ "enum": [
"Like", "Like",
@@ -5853,6 +5988,16 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"UpdateReviewPromptRequest": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"nullable": true
}
},
"additionalProperties": false
},
"UpdateReviewRequest": { "UpdateReviewRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -21,3 +21,7 @@ public record ReviewDto(
public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text); public record CreateReviewRequest(int LectureId, ReviewRating Rating, string? Text);
public record UpdateReviewRequest(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<CourseTag> CourseTags { get; set; } = null!;
public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!; public DbSet<LectureEnrollment> LectureEnrollments { get; set; } = null!;
public DbSet<Review> Reviews { 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<Achievement> Achievements { get; set; } = null!;
public DbSet<UserAchievement> UserAchievements { get; set; } = null!; public DbSet<UserAchievement> UserAchievements { get; set; } = null!;
public DbSet<CoinTransaction> CoinTransactions { 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.Net.Http.Json;
using System.Text;
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UniVerse.Application.Interfaces; using UniVerse.Application.Interfaces;
using UniVerse.Application.Prompts;
namespace UniVerse.Infrastructure.ExternalServices; namespace UniVerse.Infrastructure.ExternalServices;
@@ -11,25 +11,25 @@ public class LlmClient : ILlmClient
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly IReviewPromptService _reviewPrompts;
private readonly ILogger<LlmClient> _logger; 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) public async Task<LlmReviewAnalysis> AnalyzeReviewAsync(string reviewText, string lectureContext)
{ {
var prompt = $""" var promptSetting = await _reviewPrompts.GetAsync();
Analyze the following student review of a lecture. Return a JSON object with: var prompt = ReviewPromptTemplate.Render(promptSetting.Prompt, reviewText, lectureContext);
- 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 request = new 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); 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 => modelBuilder.Entity("UniVerse.Domain.Entities.StudentProfile", b =>
{ {
b.Property<int>("Id") 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.");
}
}
+8
View File
@@ -11,10 +11,12 @@ import type {
PagedResult, PagedResult,
ReviewDto, ReviewDto,
ReviewQuery, ReviewQuery,
ReviewPromptDto,
SyncResultDto, SyncResultDto,
SyncScheduleRequest, SyncScheduleRequest,
SyncStatusDto, SyncStatusDto,
TagDto, TagDto,
UpdateReviewPromptRequest,
UserAchievementDto, UserAchievementDto,
CurrentUserDto, CurrentUserDto,
UserDto, UserDto,
@@ -183,6 +185,12 @@ export const reviewsApi = {
method: 'POST', method: 'POST',
body: JSON.stringify({ lectureId: Number(lectureId), rating, text }), body: JSON.stringify({ lectureId: Number(lectureId), rating, text }),
}), }),
getPrompt: () => apiRequest<ReviewPromptDto>('/reviews/llm-prompt'),
updatePrompt: (payload: UpdateReviewPromptRequest) =>
apiRequest<ReviewPromptDto>('/reviews/llm-prompt', {
method: 'PUT',
body: JSON.stringify(payload),
}),
listPage: listReviewsPage, listPage: listReviewsPage,
async list(query: ReviewQuery = { PageSize: 100 }) { async list(query: ReviewQuery = { PageSize: 100 }) {
return (await listReviewsPage(query)).items return (await listReviewsPage(query)).items
+9
View File
@@ -136,6 +136,15 @@ export interface ReviewDto {
createdAt: string createdAt: string
} }
export interface ReviewPromptDto {
prompt: string
updatedAt?: string | null
}
export interface UpdateReviewPromptRequest {
prompt: string
}
export interface AchievementDto { export interface AchievementDto {
id: number id: number
name?: string | null name?: string | null
+157 -1
View File
@@ -41,6 +41,13 @@ const totalCount = ref(0)
const totalPages = ref(0) const totalPages = ref(0)
const reanalyzingId = ref<number | null>(null) const reanalyzingId = ref<number | null>(null)
const error = ref('') const error = ref('')
const promptText = ref('')
const savedPromptText = ref('')
const promptUpdatedAt = ref<string | null>(null)
const promptLoading = ref(false)
const promptSaving = ref(false)
const promptError = ref('')
const promptSuccess = ref('')
const rows = computed(() => const rows = computed(() =>
reviews.value.map((review) => ({ reviews.value.map((review) => ({
@@ -73,12 +80,63 @@ const pageStart = computed(() =>
const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value)) const pageEnd = computed(() => Math.min(page.value * pageSize.value, totalCount.value))
const canGoPrev = computed(() => page.value > 1) const canGoPrev = computed(() => page.value > 1)
const canGoNext = computed(() => page.value < totalPages.value) 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) { function formatQuality(value: number | null | undefined) {
if (value === null || value === undefined) return '—' if (value === null || value === undefined) return '—'
return Number(value).toFixed(2) 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() { async function fetchReviews() {
loading.value = true loading.value = true
error.value = '' error.value = ''
@@ -115,7 +173,10 @@ async function reanalyze(id: number) {
} }
} }
onMounted(fetchReviews) onMounted(() => {
void fetchPrompt()
void fetchReviews()
})
</script> </script>
<template> <template>
@@ -130,6 +191,38 @@ onMounted(fetchReviews)
</button> </button>
</div> </div>
<GlassCard>
<div class="prompt-header">
<div>
<div class="section-title">Промпт LLM-анализа</div>
<p class="prompt-subtitle">
Шаблон применяется к новым проверкам и ручному повторному анализу отзывов.
</p>
</div>
<span class="prompt-status">{{ promptStatusLabel }}</span>
</div>
<form class="prompt-form" @submit.prevent="savePrompt">
<textarea
v-model="promptText"
class="glass-input prompt-textarea"
rows="9"
:disabled="promptLoading || promptSaving"
placeholder="Загрузка промпта..."
></textarea>
<div class="prompt-footer">
<div class="prompt-messages">
<span class="prompt-hint">Обязательные плейсхолдеры: {lectureContext}, {reviewText}</span>
<span v-if="promptError" class="prompt-error">{{ promptError }}</span>
<span v-else-if="promptSuccess" class="prompt-success">{{ promptSuccess }}</span>
</div>
<button class="btn-primary" type="submit" :disabled="!canSavePrompt">
{{ promptSaving ? 'Сохраняем...' : 'Сохранить промпт' }}
</button>
</div>
</form>
</GlassCard>
<GlassCard> <GlassCard>
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" /> <EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
<EmptyState <EmptyState
@@ -240,6 +333,69 @@ onMounted(fetchReviews)
margin: 4px 0 0; margin: 4px 0 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.section-title {
font-size: 16px;
font-weight: 700;
}
.prompt-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.prompt-subtitle {
margin: 4px 0 0;
color: var(--color-text-secondary);
font-size: 13px;
}
.prompt-status {
flex: 0 0 auto;
border: 1px solid var(--color-border-glass);
border-radius: 999px;
padding: 5px 10px;
background: var(--color-white-a72);
color: var(--color-text-secondary);
font-size: 12px;
}
.prompt-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.prompt-textarea {
min-height: 250px;
resize: vertical;
line-height: 1.45;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
}
.prompt-textarea:disabled {
color: var(--color-text-secondary);
cursor: wait;
}
.prompt-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.prompt-messages {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 240px;
color: var(--color-text-secondary);
font-size: 12px;
}
.prompt-error {
color: var(--color-error);
}
.prompt-success {
color: var(--color-success-text);
}
.review-text { .review-text {
display: inline-block; display: inline-block;
max-width: 320px; max-width: 320px;