Add test submission feature for LLM checks without creating submissions; update API and UI components to support new functionality, enhancing task evaluation for teachers and challenge authors. Update localization for test check messages in English and Russian.

This commit is contained in:
2025-12-10 12:41:03 +03:00
parent 173954f685
commit ec79dd58aa
6 changed files with 408 additions and 0 deletions

241
docs/updateAPI.md Normal file
View File

@@ -0,0 +1,241 @@
## Обновление API Challenge Service
Документ для frontend-разработчика. Описывает НОВЫЕ возможности и требования к клиенту.
Содержит два блока изменений:
- **Управление видимостью цепочек заданий** (поле `isActive` и новый админский эндпоинт).
- **Тестовая проверка решения задания админом** (флаг `isTest` в `/submit`).
---
## 1. Управление видимостью цепочек заданий
### 1.1. Новое поле в модели цепочки
**Поле `isActive`**
- **Тип**: `boolean`
- **По умолчанию**: `true`
- **Смысл**: определяет, видна ли цепочка обычным пользователям в пользовательском списке.
> В базе: поле уже есть в модели `ChallengeChain`, на фронте его нужно учитывать в админских интерфейсах.
---
### 1.2. Пользовательский список цепочек
#### `GET /api/challenge/chains`
- **Назначение**: список цепочек для студентов/обычных пользователей.
- **Фильтрация на бэке**: возвращаются **только цепочки с `isActive: true`**.
- **Доступ**: без специальных ролей.
**Гарантии для фронтенда:**
- Выключенные / черновые цепочки **никогда** не попадут в этот список.
- Можно строить каталог цепочек, не фильтруя по `isActive` на клиенте.
Упрощённая структура элемента:
```json
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "..."
// Для не-преподавателей поля hiddenInstructions и creator отсутствуют
}
],
"isActive": true
}
```
**Требования к фронтенду:**
- Для пользовательских экранов достаточно этого эндпоинта, **дополнительную фильтрацию по активности делать не нужно**.
---
### 1.3. Админский список цепочек
#### `GET /api/challenge/chains/admin`
- **Назначение**: полный список цепочек (и включённых, и выключенных) для админских/преподавательских экранов.
- **Фильтрации по активности нет** — возвращаются **все** цепочки.
- **Доступ**: только роли `teacher` или `challenge-author`.
- Включает все данные по задачам, в т.ч. `hiddenInstructions`, `creator`.
Пример ответа (фрагмент):
```json
{
"error": null,
"data": [
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "...",
"hiddenInstructions": "...",
"creator": { "sub": "...", "preferred_username": "teacher1" }
}
],
"isActive": true,
"createdAt": "2023-10-29T12:00:00.000Z",
"updatedAt": "2023-10-29T12:00:00.000Z"
}
]
}
```
**Требования к фронтенду (админский UI):**
- Использовать этот эндпоинт для экранов управления цепочками.
- Показывать состояние активности (`isActive`) каждой цепочки (badge, тумблер и т.п.).
- При ошибке 403 (нет роли `teacher` / `challenge-author`) отображать сообщение об отсутствии доступа и, при необходимости, перенаправлять на пользовательский список.
---
### 1.4. Создание и обновление цепочек с учётом активности
#### `POST /api/challenge/chain`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса:**
```json
{
"name": "Основы программирования",
"taskIds": ["...", "..."],
"isActive": true // опционально, по умолчанию true
}
```
- Если `isActive` не передан, цепочка создаётся **активной**.
**Требования к фронтенду:**
- На форме создания цепочки можно:
- либо не показывать тумблер активности (все новые будут активными),
- либо добавить переключатель «Активна» и передавать `isActive: false` для черновиков.
#### `PUT /api/challenge/chain/:chainId`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса (все поля опциональны):**
```json
{
"name": "Новое имя",
"taskIds": ["..."],
"isActive": false
}
```
- Если `isActive` передан, его значение меняет активность цепочки.
- Если `isActive` не передан, активность не меняется.
**Сценарии:**
- Включить цепочку: `PUT /api/challenge/chain/:id` с `{ "isActive": true }`.
- Выключить цепочку (спрятать из пользовательского списка): `{ "isActive": false }`.
- Переименовать / поменять задачи без изменения активности: отправлять только `name` / `taskIds` без поля `isActive`.
**Требования к UI:**
- На экране «управление цепочками» (данные из `/chains/admin`):
- показывать `isActive`;
- давать возможность включать/выключать цепочку (тумблер → вызов `PUT /chain/:id` с нужным `isActive`).
---
## 2. Тестовая проверка решения задания (без записи прогресса)
Добавлен режим тестовой проверки решения, который позволяет **преподавателю/автору** проверить ответ через LLM **без создания попытки и без постановки в очередь**.
### 2.1. Расширение эндпоинта отправки решения
#### `POST /api/challenge/submit`
К существующему API добавлен новый опциональный флаг в теле запроса:
```json
{
"userId": "...",
"taskId": "...",
"result": "...",
"isTest": true // НОВОЕ: опциональный флаг
}
```
### 2.2. Обычный режим (без `isTest`)
- Если `isTest` **не передан** или `false` — поведение **НЕ изменилось**:
- проверяется существование пользователя по `userId`;
- считается количество попыток;
- создаётся `ChallengeSubmission`;
- попытка ставится в очередь на проверку через LLM;
- в ответе фронтенд получает `queueId` и `submissionId`.
### 2.3. Тестовый режим (`isTest: true`)
- Доступен только для ролей `teacher` / `challenge-author` (проверка через `isTeacher(req, true)`).
- **Не создаётся** запись `ChallengeSubmission`.
- **Не используется** очередь проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен**.
- Сразу вызывается LLM и возвращается результат проверки.
**Пример запроса (тестовый режим):**
```http
POST /api/challenge/submit
Content-Type: application/json
Authorization: Bearer <keycloak_token_teacher_or_author>
{
"userId": "any-or-dummy-id",
"taskId": "507f1f77bcf86cd799439012",
"result": "function solve() { ... }",
"isTest": true
}
```
> `userId` формально обязателен по схеме, но в тестовом режиме не используется на бэке. Можно передавать любой корректный ObjectId.
**Пример ответа (тестовый режим):**
```json
{
"error": null,
"data": {
"isTest": true,
"status": "accepted", // или "needs_revision"
"feedback": "Развёрнутый комментарий от LLM"
}
}
```
При отсутствии прав (нет роли `teacher` / `challenge-author`) вернётся 403.
### 2.4. Требования к фронтенду
- **Где использовать тестовый режим**:
- только в админских/преподавательских интерфейсах (например, экран настройки задания или предпросмотр проверки);
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю.
- **Где НЕ использовать**:
- в пользовательском флоу сдачи заданий студентами — там должен использоваться обычный режим **без** `isTest`.
- **UI-ожидания**:
- показывать администратору статус (`accepted` / `needs_revision`) и `feedback`;
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**.
---
## 3. Краткое резюме
- Для цепочек:
- пользовательский список: `GET /api/challenge/chains` → только активные (`isActive: true`);
- админский список: `GET /api/challenge/chains/admin` → все цепочки + управление `isActive` через `POST/PUT /chain`.
- Для отправки решений:
- обычный режим без `isTest` — всё как раньше (очередь, попытки, статистика);
- тестовый режим с `isTest: true` — только для `teacher/challenge-author`, без записи прогресса, сразу возвращает результат проверки.

View File

@@ -54,6 +54,18 @@
"challenge.admin.tasks.delete.confirm.title": "Delete task", "challenge.admin.tasks.delete.confirm.title": "Delete task",
"challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.", "challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.",
"challenge.admin.tasks.delete.confirm.button": "Delete", "challenge.admin.tasks.delete.confirm.button": "Delete",
"challenge.admin.tasks.test.title": "Test check of answer",
"challenge.admin.tasks.test.description": "Send a sample answer to see how the LLM will evaluate this task with hidden instructions applied. This check does not affect statistics or attempt history.",
"challenge.admin.tasks.test.field.answer": "Answer for test check",
"challenge.admin.tasks.test.field.answer.placeholder": "Enter a sample solution as a student would write it...",
"challenge.admin.tasks.test.field.answer.helper": "The answer is sent in test mode (isTest: true) — no submission is created and no queue job is scheduled.",
"challenge.admin.tasks.test.button.run": "Run test check",
"challenge.admin.tasks.test.success": "Test check completed",
"challenge.admin.tasks.test.error": "Failed to run test check",
"challenge.admin.tasks.test.forbidden": "You don't have permissions for test checking. Teacher or challenge-author role is required.",
"challenge.admin.tasks.test.validation.fill.answer": "Enter an answer text for test check",
"challenge.admin.tasks.test.status.accepted": "✅ Answer accepted",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Answer needs revision",
"challenge.admin.chains.updated": "Chain updated", "challenge.admin.chains.updated": "Chain updated",
"challenge.admin.chains.created": "Chain created", "challenge.admin.chains.created": "Chain created",
"challenge.admin.chains.validation.enter.name": "Enter chain name", "challenge.admin.chains.validation.enter.name": "Enter chain name",

View File

@@ -53,6 +53,18 @@
"challenge.admin.tasks.delete.confirm.title": "Удалить задание", "challenge.admin.tasks.delete.confirm.title": "Удалить задание",
"challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.", "challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.",
"challenge.admin.tasks.delete.confirm.button": "Удалить", "challenge.admin.tasks.delete.confirm.button": "Удалить",
"challenge.admin.tasks.test.title": "Тестовая проверка ответа",
"challenge.admin.tasks.test.description": "Отправьте пример ответа, чтобы проверить, как LLM будет оценивать это задание с учётом скрытых инструкций. Эта проверка не попадает в статистику и историю попыток.",
"challenge.admin.tasks.test.field.answer": "Ответ для тестовой проверки",
"challenge.admin.tasks.test.field.answer.placeholder": "Введите пример решения так, как его написал бы студент...",
"challenge.admin.tasks.test.field.answer.helper": "Ответ отправляется в режиме тестовой проверки (isTest: true) — без создания попытки и постановки в очередь.",
"challenge.admin.tasks.test.button.run": "Проверить ответ",
"challenge.admin.tasks.test.success": "Тестовая проверка выполнена",
"challenge.admin.tasks.test.error": "Не удалось выполнить тестовую проверку",
"challenge.admin.tasks.test.forbidden": "Недостаточно прав для тестовой проверки. Нужна роль преподавателя или автора челленджа.",
"challenge.admin.tasks.test.validation.fill.answer": "Введите текст ответа для тестовой проверки",
"challenge.admin.tasks.test.status.accepted": "✅ Ответ принят (accepted)",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Ответ требует доработки (needs_revision)",
"challenge.admin.chains.updated": "Цепочка обновлена", "challenge.admin.chains.updated": "Цепочка обновлена",
"challenge.admin.chains.created": "Цепочка создана", "challenge.admin.chains.created": "Цепочка создана",
"challenge.admin.chains.validation.enter.name": "Введите название цепочки", "challenge.admin.chains.validation.enter.name": "Введите название цепочки",

View File

@@ -13,6 +13,9 @@ import type {
UpdateTaskRequest, UpdateTaskRequest,
CreateChainRequest, CreateChainRequest,
UpdateChainRequest, UpdateChainRequest,
SubmitRequest,
TestSubmissionResult,
APIResponse,
} from '../../types/challenge' } from '../../types/challenge'
export const api = createApi({ export const api = createApi({
@@ -141,6 +144,21 @@ export const api = createApi({
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body, transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
providesTags: ['Submission'], providesTags: ['Submission'],
}), }),
// Test submission (LLM check without creating a real submission)
testSubmission: builder.mutation<TestSubmissionResult, SubmitRequest>({
query: ({ userId, taskId, result, isTest = true }) => ({
url: '/challenge/submit',
method: 'POST',
body: {
userId,
taskId,
result,
isTest,
},
}),
transformResponse: (response: APIResponse<TestSubmissionResult>) => response.data,
}),
}), }),
}) })
@@ -159,5 +177,6 @@ export const {
useGetSystemStatsV2Query, useGetSystemStatsV2Query,
useGetUserStatsQuery, useGetUserStatsQuery,
useGetUserSubmissionsQuery, useGetUserSubmissionsQuery,
useTestSubmissionMutation,
} = api } = api

View File

@@ -19,6 +19,7 @@ import {
useGetTaskQuery, useGetTaskQuery,
useCreateTaskMutation, useCreateTaskMutation,
useUpdateTaskMutation, useUpdateTaskMutation,
useTestSubmissionMutation,
} from '../../__data__/api/api' } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls' import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
@@ -35,11 +36,15 @@ export const TaskFormPage: React.FC = () => {
}) })
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation() const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation() const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
const [testSubmission, { isLoading: isTesting }] = useTestSubmissionMutation()
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('') const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false) const [showDescPreview, setShowDescPreview] = useState(false)
const [testAnswer, setTestAnswer] = useState('')
const [testStatus, setTestStatus] = useState<string | null>(null)
const [testFeedback, setTestFeedback] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (task) { if (task) {
@@ -106,6 +111,58 @@ export const TaskFormPage: React.FC = () => {
} }
} }
const handleTestSubmit = async () => {
if (!task || !id) {
return
}
if (!testAnswer.trim()) {
toaster.create({
title: t('challenge.admin.common.validation.error'),
description: t('challenge.admin.tasks.test.validation.fill.answer'),
type: 'error',
})
return
}
setTestStatus(null)
setTestFeedback(null)
try {
const dummyUserId = task.creator?.sub || task.id
const result = await testSubmission({
userId: dummyUserId,
taskId: task.id,
result: testAnswer.trim(),
isTest: true,
}).unwrap()
setTestStatus(result.status)
setTestFeedback(result.feedback ?? null)
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.tasks.test.success'),
type: 'success',
})
} catch (err: unknown) {
const isForbidden =
err &&
typeof err === 'object' &&
'status' in err &&
(err as { status?: number }).status === 403
toaster.create({
title: t('challenge.admin.common.error'),
description: isForbidden
? t('challenge.admin.tasks.test.forbidden')
: t('challenge.admin.tasks.test.error'),
type: 'error',
})
}
}
if (isEdit && isLoadingTask) { if (isEdit && isLoadingTask) {
return <LoadingSpinner message={t('challenge.admin.tasks.loading')} /> return <LoadingSpinner message={t('challenge.admin.tasks.loading')} />
} }
@@ -309,6 +366,57 @@ export const TaskFormPage: React.FC = () => {
</Box> </Box>
)} )}
{/* Test submission (LLM check) */}
{isEdit && task && (
<Box p={4} bg="teal.50" borderRadius="md" borderWidth="1px" borderColor="teal.200">
<Text fontWeight="bold" mb={2} color="teal.900">
{t('challenge.admin.tasks.test.title')}
</Text>
<Text fontSize="sm" mb={3} color="teal.800">
{t('challenge.admin.tasks.test.description')}
</Text>
<Field.Root>
<Field.Label>{t('challenge.admin.tasks.test.field.answer')}</Field.Label>
<Textarea
value={testAnswer}
onChange={(e) => setTestAnswer(e.target.value)}
placeholder={t('challenge.admin.tasks.test.field.answer.placeholder')}
rows={6}
fontFamily="monospace"
disabled={isTesting}
/>
<Field.HelperText>
{t('challenge.admin.tasks.test.field.answer.helper')}
</Field.HelperText>
</Field.Root>
<HStack mt={3} align="flex-start" justify="space-between" gap={4}>
<Button
onClick={handleTestSubmit}
colorPalette="teal"
disabled={isTesting || !testAnswer.trim()}
>
{t('challenge.admin.tasks.test.button.run')}
</Button>
{(testStatus || testFeedback) && (
<Box flex="1" p={3} bg="white" borderRadius="md" borderWidth="1px" borderColor="teal.100">
{testStatus && (
<Text fontSize="sm" fontWeight="medium" mb={1}>
{t(`challenge.admin.tasks.test.status.${testStatus}`)}
</Text>
)}
{testFeedback && (
<Text fontSize="sm" whiteSpace="pre-wrap">
{testFeedback}
</Text>
)}
</Box>
)}
</HStack>
</Box>
)}
{/* Actions */} {/* Actions */}
<HStack gap={3} justify="flex-end"> <HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}> <Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>

View File

@@ -226,3 +226,19 @@ export interface SystemStatsV2 {
chainsDetailed: ChainDetailed[] chainsDetailed: ChainDetailed[]
} }
// ========== Submissions / Checking ==========
export interface SubmitRequest {
userId: string
taskId: string
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
}
export interface TestSubmissionResult {
isTest: true
status: Exclude<SubmissionStatus, 'pending' | 'in_progress'>
feedback?: string
}