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 d6fc47794e
commit 76a9a915c2
15 changed files with 305 additions and 18 deletions

View File

@@ -37,6 +37,7 @@ import java.nio.charset.StandardCharsets
class AdminController(
private val api: ApiClient,
private val markdown: ru.ysu.lms.markdown.MarkdownRenderer,
private val mapper: tools.jackson.databind.ObjectMapper,
) {
@GetMapping
@@ -119,6 +120,7 @@ class AdminController(
val labs = api.listLabs(t)
val grades = api.listGrades(t)
val gradeBy = grades.associate { "${it.studentId}|${it.labId}" to it.score }
val commentsBy = grades.associate { "${it.studentId}|${it.labId}" to it.comments.map { ct -> ct.text to ct.tone } }
val studentGrades = group.students.map { s ->
val scores = labs.map { lab -> gradeBy["${s.id}|${lab.id}"] }
val nums = scores.mapNotNull { it?.toBigDecimalOrNull() }
@@ -128,11 +130,17 @@ class AdminController(
studentNo = s.studentNo,
scores = scores,
average = if (nums.isEmpty()) null else nums.map { it.toDouble() }.average(),
comments = labs.map { lab -> commentsBy["${s.id}|${lab.id}"] ?: emptyList() },
commentsJson = mapper.writeValueAsString(labs.map { lab ->
(commentsBy["${"$"}{s.id}|${"$"}{lab.id}"] ?: emptyList()).map { (t, tone) -> mapOf("text" to t, "tone" to tone) }
}),
)
}
model.addAttribute("group", group)
model.addAttribute("labs", labs)
model.addAttribute("studentGrades", studentGrades)
model.addAttribute("commentOptions", api.suggestComments(t, ""))
return "admin/group"
}
@@ -142,10 +150,12 @@ class AdminController(
@RequestParam studentId: String,
@RequestParam labId: String,
@RequestParam score: String,
@RequestParam(required = false) commentsJson: String?,
session: HttpSession,
redirect: RedirectAttributes,
): String = try {
api.setGrade(token(session), GradeSetPayload(studentId, labId, score))
val comments = parseComments(commentsJson ?: "[]")
api.setGrade(token(session), GradeSetPayload(studentId, labId, score, comments))
redirect.addFlashAttribute("notice", "Оценка сохранена")
"redirect:/admin/groups/$id"
} catch (ex: ApiException) {
@@ -624,6 +634,24 @@ class AdminController(
"redirect:/admin/labs/$labId"
}
private fun parseComments(json: String): List<ru.ysu.lms.web.api.CommentPayload> {
if (json.isBlank() || json == "[]") return emptyList()
return try {
val node = mapper.readTree(json)
buildList {
node.forEach { el ->
val text = el.path("text").asText("").trim()
if (text.isNotEmpty()) {
add(ru.ysu.lms.web.api.CommentPayload(text, el.path("tone").asText("neutral")))
}
}
}
} catch (_: Exception) {
emptyList()
}
}
private fun token(session: HttpSession): String =
session.getAttribute(SessionKeys.TOKEN) as? String
?: throw ApiException(401, "Сессия истекла, войдите заново")

View File

@@ -31,4 +31,6 @@ data class AdminStudentGrades(
val studentNo: String,
val scores: List<String?>,
val average: Double?,
val comments: List<List<Pair<String, String>>>,
val commentsJson: String,
)

View File

@@ -306,6 +306,15 @@ class ApiClient(
.retrieve().body(GradeDto::class.java)
}
fun suggestComments(token: String, query: String): List<CommentDto> {
val type = object : ParameterizedTypeReference<List<CommentDto>>() {}
return call {
client.get().uri("/api/v1/comment-templates?q={q}", query)
.header(HttpHeaders.AUTHORIZATION, bearer(token))
.retrieve().body(type)
} ?: emptyList()
}
fun myGrades(token: String): List<GradeDto> {
val type = object : ParameterizedTypeReference<List<GradeDto>>() {}
return call {

View File

@@ -210,8 +210,12 @@ data class GradeDto(
val studentId: String = "",
val labId: String = "",
val score: String = "",
val comments: List<CommentDto> = emptyList(),
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class CommentDto(val text: String = "", val tone: String = "neutral")
@JsonIgnoreProperties(ignoreUnknown = true)
data class SubmissionDto(
val id: String = "",
@@ -226,8 +230,11 @@ data class GradeSetPayload(
val studentId: String,
val labId: String,
val score: String,
val comments: List<CommentPayload> = emptyList(),
)
data class CommentPayload(val text: String, val tone: String)
data class LabCreatePayload(
val number: Int,
val title: String,

View File

@@ -84,7 +84,9 @@ class StudentController(
model.addAttribute("assignment", assignment)
model.addAttribute("statementHtml", assignment?.statementHtml)
model.addAttribute("submissions", api.mySubmissions(t, id))
model.addAttribute("grade", api.myGrades(t).firstOrNull { it.labId == id }?.score)
val myGrade = api.myGrades(t).firstOrNull { it.labId == id }
model.addAttribute("grade", myGrade?.score)
model.addAttribute("gradeComments", myGrade?.comments ?: emptyList<ru.ysu.lms.web.api.CommentDto>())
model.addAttribute("deadlineMsk", DateUtils.formatMsk(lab.deadlineAt))
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt))
val defense = api.myDefense(t, id)

View File

@@ -217,4 +217,17 @@ code.code { padding: 0.125rem 0.5rem; border-radius: 0.25rem; font-family: monos
.form-card { padding: 1.5rem; }
/* Header logo */
.link-inherit { font-size: 0.875rem; }
.link-inherit { font-size: 0.875rem; }/* Chips (оценочные комментарии) */
.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; }
/* Слоты комментариев в диалоге */
.cs-row { display: flex; gap: 6px; align-items: center; }
.chip { display: inline-block; margin: 2px 4px 2px 0; padding: 2px 8px; border-radius: 9999px; font-size: 11.5px; line-height: 1.5; }
.chip-pos { background: #16803c; color: #fff; }
.chip-neu { background: #5d6570; color: #fff; }
.chip-neg { background: #b3283b; color: #fff; }
/* Defense timer */

View File

@@ -46,7 +46,7 @@
</div>
<h2 class="section-title">Баллы за лабораторные</h2>
<p class="text-sm text-muted mb-4">Введите балл (0–5) и нажмите «OK». Средний балл считается по оценённым лабам.</p>
<p class="text-sm text-muted mb-4">Нажмите ✎ в ячейке балла — откроется ввод оценки с комментариями. Средний балл считается по оценённым лабам.</p>
<div class="card card-flush mb-6 overflow-x-auto">
<table>
<thead>
@@ -62,14 +62,21 @@
<tr th:each="row : ${studentGrades}">
<td th:text="${row.fullName}">—</td>
<td th:each="score, iter : ${row.scores}">
<form class="inline-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post">
<input type="hidden" name="studentId" th:value="${row.studentId}">
<input type="hidden" name="labId" th:value="${labs[iter.index].id}">
<input class="input input-sm input-score" name="score" type="number" step="0.1" min="0" max="5"
th:value="${score}">
<button class="btn btn-secondary btn-sm" type="submit">OK</button>
<input type="hidden" name="_csrf" th:value="${csrfToken}">
</form>
<div>
<div class="score-cell-root">
<form class="inline-form">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input class="input input-sm input-score" type="number" step="0.1" min="0" max="5" th:value="${score}" readonly>
<button class="btn btn-primary btn-sm" type="button" onclick="openGradeEditor(this)"
th:attr="data-student=${row.studentId},data-student-name=${row.fullName},data-lab=${labs[iter.index].id},data-lab-name='ЛР ' + ${labs[iter.index].number},data-score=${score},data-comments=${row.commentsJson},data-ci=${iter.index}">✎</button>
</form>
</div>
<div>
<span th:each="c : ${row.comments[iter.index]}" class="chip"
th:classappend="${c.second == 'positive'} ? chip-pos : (${c.second == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.first}">коммент</span>
</div>
</div>
</td>
<td class="text-center font-semibold">
<span th:if="${row.average != null}" th:text="${#numbers.formatDecimal(row.average, 1, 2)}">—</span>
@@ -144,6 +151,98 @@
</form>
</div>
</dialog>
<dialog id="modal-grade" class="modal">
<div class="modal-header">
<h2 class="modal-title" id="grade-title">Оценка и комментарии</h2>
<button class="modal-close" type="button" onclick="closeModal('modal-grade')">✕</button>
</div>
<div class="modal-body">
<form class="form-stack" id="grade-form" th:action="@{/admin/groups/{id}/grades(id=${group.id})}" method="post"
onsubmit="return csSerialize()">
<input type="hidden" name="_csrf" th:value="${csrfToken}">
<input type="hidden" name="studentId" id="grade-student">
<input type="hidden" name="labId" id="grade-lab">
<input type="hidden" name="commentsJson" id="grade-comments">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<div id="cs-rows"></div>
<button class="btn btn-secondary btn-sm" type="button" onclick="csRow('','neutral')">+ комментарий</button>
<button class="btn btn-primary" type="submit">Сохранить</button>
</form>
</div>
</dialog>
<datalist id="cs-list"></datalist>
<script>
var CS_TEMPLATES = [];
</script>
<script th:inline="javascript">
CS_TEMPLATES = /*[[${commentOptions}]]*/ [];
function openGradeEditor(btn) {
var all = JSON.parse(btn.getAttribute('data-comments') || '[]');
var list = all[parseInt(btn.getAttribute('data-ci') || '0', 10)] || [];
document.getElementById('grade-student').value = btn.getAttribute('data-student');
document.getElementById('grade-lab').value = btn.getAttribute('data-lab');
document.getElementById('grade-who').value = btn.getAttribute('data-student-name');
document.getElementById('grade-title').textContent = btn.getAttribute('data-lab-name') + ' — ' + btn.getAttribute('data-student-name');
var sc = document.getElementById('grade-score');
sc.value = btn.getAttribute('data-score') || '';
var box = document.getElementById('cs-rows');
box.innerHTML = '';
list.forEach(function (c) { csRow(c.text, c.tone); });
if (!list.length) csRow('', 'neutral');
openModal('modal-grade');
}
function csRow(text, tone) {
var box = document.getElementById('cs-rows');
var row = document.createElement('div');
row.className = 'cs-row';
var inp = document.createElement('input');
inp.className = 'input cs-text'; inp.placeholder = 'Комментарий (подскажем частые)';
inp.setAttribute('list', 'cs-list');
inp.value = text;
inp.addEventListener('input', csFilter);
var sel = document.createElement('select');
sel.className = 'input cs-tone'
[['positive', '+'], ['neutral', 'o'], ['negative', '−']].forEach(function (o) {
var op = document.createElement('option'); op.value = o[0]; op.textContent = o[1]; sel.appendChild(op);
});
sel.value = tone || 'neutral';
var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'btn btn-danger btn-sm cs-del'; rm.textContent = '✕';
rm.addEventListener('click', function () { row.remove(); });
row.appendChild(inp); row.appendChild(sel); row.appendChild(rm);
box.appendChild(row);
}
function csFilter(ta) {
var list = document.getElementById('cs-list');
list.innerHTML = '';
var q = (ta.target.value || '').toLowerCase().trim();
CS_TEMPLATES
.filter(function (t) { return !q || t.text.toLowerCase().indexOf(q) !== -1; })
.slice(0, 8)
.forEach(function (t) {
var op = document.createElement('option');
op.value = t.text;
list.appendChild(op);
});
}
function csSerialize() {
var out = [];
document.querySelectorAll('#cs-rows .cs-row').forEach(function (r) {
var text = r.querySelector('.cs-text').value.trim();
if (text) out.push({ text: text, tone: r.querySelector('.cs-tone').value });
});
document.getElementById('grade-comments').value = JSON.stringify(out);
return true;
}
</script>
</div>
</div>
</div>
</body>
</html>
</html>

View File

@@ -8,6 +8,9 @@
<h1 class="page-title" th:text="|Лабораторная работа ${lab.number}. ${lab.title}|">ЛР</h1>
<div class="flex gap-2 text-sm">
<span th:if="${grade != null}" class="badge badge-ok">Оценка: <span th:text="${grade}"></span></span>
<span th:each="c : ${gradeComments}" class="chip"
th:classappend="${c.tone == 'positive'} ? chip-pos : (${c.tone == 'negative'} ? chip-neg : chip-neu)"
th:text="${c.text}">коммент</span>
<span th:if="${assignment != null}" class="badge badge-warn">Вариант <span class="font-mono" th:text="${assignment.variantNumber}"></span></span>
</div>
</div>