Dev #11
@@ -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"),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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!;
|
||||
|
||||
+27
@@ -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
|
||||
{
|
||||
|
||||
Generated
+1135
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.");
|
||||
}
|
||||
}
|
||||
@@ -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<ReviewPromptDto>('/reviews/llm-prompt'),
|
||||
updatePrompt: (payload: UpdateReviewPromptRequest) =>
|
||||
apiRequest<ReviewPromptDto>('/reviews/llm-prompt', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listPage: listReviewsPage,
|
||||
async list(query: ReviewQuery = { PageSize: 100 }) {
|
||||
return (await listReviewsPage(query)).items
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,13 @@ const totalCount = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const reanalyzingId = ref<number | null>(null)
|
||||
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(() =>
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -130,6 +191,38 @@ onMounted(fetchReviews)
|
||||
</button>
|
||||
</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>
|
||||
<EmptyState v-if="error" title="Не удалось загрузить отзывы" :subtitle="error" />
|
||||
<EmptyState
|
||||
@@ -240,6 +333,69 @@ onMounted(fetchReviews)
|
||||
margin: 4px 0 0;
|
||||
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 {
|
||||
display: inline-block;
|
||||
max-width: 320px;
|
||||
|
||||
Reference in New Issue
Block a user