Phase 4: grade comments with frequency-ranked suggestions

- progress comment_templates (tone + use_count) and grade_comments tables;
  setGrade now accepts comments list; templates created/bumped automatically;
  GET /api/v1/comment-templates?q= returns suggestions ranked by frequency
- admin group page: per-grade ✎ dialog (score + editable comment rows with
  tone, datalist suggestions), chips under each score, data-comments JSON attr
- student lab page: score + colored tone chips
- fixed damaged button/attributes from earlier manual template surgery
This commit is contained in:
2026-09-12 01:51:23 +03:00
parent ccd31263d5
commit d355319a20
15 changed files with 305 additions and 18 deletions

View File

@@ -20,7 +20,7 @@ spring:
- id: progress
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
predicates:
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/submissions/**
- Path=/api/v1/assign,/api/v1/assign/**,/api/v1/defenses,/api/v1/defenses/**,/api/v1/assignments/**,/api/v1/levels,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute,/api/v1/distribute/**,/api/v1/grades,/api/v1/grades/**,/api/v1/comment-templates,/api/v1/submissions/**
management:
endpoints:

View File

@@ -128,12 +128,17 @@ data class GradeSetRequest(
val studentId: String,
val labId: String,
val score: BigDecimal,
val comments: List<CommentInput> = emptyList(),
)
data class CommentInput(val text: String, val tone: String = "neutral")
data class CommentOut(val text: String, val tone: String)
data class GradeResponse(
val studentId: String,
val labId: String,
val score: BigDecimal,
val comments: List<CommentOut> = emptyList(),
)
data class SubmissionDto(

View File

@@ -84,6 +84,10 @@ class ProgressController(
fun myGrades(@AuthenticationPrincipal jwt: Jwt): List<GradeResponse> =
service.listGrades().filter { it.studentId == jwt.subject }
@GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.suggestComments(q)
@PostMapping("/me/submissions/{labId}")
fun submit(
@PathVariable labId: UUID,

View File

@@ -3,10 +3,14 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
@@ -19,6 +23,8 @@ class ProgressService(
private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
private val academic: AcademicClient,
private val identity: IdentityClient,
) {
@@ -180,16 +186,60 @@ class ProgressService(
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val key = LabGradeKey(UUID.fromString(request.studentId), UUID.fromString(request.labId))
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
return GradeResponse(request.studentId, request.labId, request.score)
replaceComments(sid, lid, request.comments)
return GradeResponse(request.studentId, request.labId, request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) })
}
private fun replaceComments(sid: UUID, lid: UUID, inputs: List<CommentInput>) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
else { t.useCount += 1; t.tone = tone; templates.save(t) }
}
}
private fun normalizeTone(tone: String): String = when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> =
grades.findAllBy().map { GradeResponse(it.studentId.toString(), it.labId.toString(), it.score) }
fun commentsOf(sid: UUID, lid: UUID): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade = gradeComments.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
@Transactional(readOnly = true)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {

View File

@@ -0,0 +1,29 @@
package ru.ysu.lms.progress.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "comment_templates")
class CommentTemplate(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true) var text: String = "",
@Column(nullable = false, length = 8) var tone: String = "neutral",
@Column(name = "use_count", nullable = false) var useCount: Int = 1,
)
@Entity
@Table(name = "grade_comments")
class GradeComment(
@Id var id: UUID = Ids.v7(),
@Column(name = "student_id", nullable = false) var studentId: UUID = UUID(0, 0),
@Column(name = "lab_id", nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var text: String = "",
@Column(nullable = false, length = 8) var tone: String = "neutral",
@Column(name = "created_at", nullable = false) var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,17 @@
package ru.ysu.lms.progress.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import java.util.UUID
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
fun findByText(text: String): CommentTemplate?
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
}
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
fun findByStudentId(studentId: UUID): List<GradeComment>
}

View File

@@ -0,0 +1,19 @@
-- Grade comments: reusable templates (frequency-ranked) + per-grade comments.
CREATE TABLE comment_templates (
id uuid PRIMARY KEY DEFAULT uuidv7(),
text text NOT NULL UNIQUE,
tone varchar(8) NOT NULL DEFAULT 'neutral', -- positive | neutral | negative
use_count integer NOT NULL DEFAULT 1
);
CREATE TABLE grade_comments (
id uuid PRIMARY KEY DEFAULT uuidv7(),
student_id uuid NOT NULL,
lab_id uuid NOT NULL,
text text NOT NULL,
tone varchar(8) NOT NULL DEFAULT 'neutral',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX grade_comments_grade_idx ON grade_comments (student_id, lab_id);