Dev #11
@@ -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"),
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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!;
|
||||||
|
|||||||
+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.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
|
||||||
{
|
{
|
||||||
|
|||||||
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);
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user