diff --git a/docs/updateAPI.md b/docs/updateAPI.md new file mode 100644 index 0000000..be7e35f --- /dev/null +++ b/docs/updateAPI.md @@ -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 + +{ + "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`, без записи прогресса, сразу возвращает результат проверки. \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index a3a6b1a..bafd2ca 100644 --- a/locales/en.json +++ b/locales/en.json @@ -54,6 +54,18 @@ "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.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.created": "Chain created", "challenge.admin.chains.validation.enter.name": "Enter chain name", diff --git a/locales/ru.json b/locales/ru.json index 16a51cd..8207298 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -53,6 +53,18 @@ "challenge.admin.tasks.delete.confirm.title": "Удалить задание", "challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.", "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.created": "Цепочка создана", "challenge.admin.chains.validation.enter.name": "Введите название цепочки", diff --git a/src/__data__/api/api.ts b/src/__data__/api/api.ts index 5c65945..26073a4 100644 --- a/src/__data__/api/api.ts +++ b/src/__data__/api/api.ts @@ -13,6 +13,9 @@ import type { UpdateTaskRequest, CreateChainRequest, UpdateChainRequest, + SubmitRequest, + TestSubmissionResult, + APIResponse, } from '../../types/challenge' export const api = createApi({ @@ -141,6 +144,21 @@ export const api = createApi({ transformResponse: (response: { body: ChallengeSubmission[] }) => response.body, providesTags: ['Submission'], }), + + // Test submission (LLM check without creating a real submission) + testSubmission: builder.mutation({ + query: ({ userId, taskId, result, isTest = true }) => ({ + url: '/challenge/submit', + method: 'POST', + body: { + userId, + taskId, + result, + isTest, + }, + }), + transformResponse: (response: APIResponse) => response.data, + }), }), }) @@ -159,5 +177,6 @@ export const { useGetSystemStatsV2Query, useGetUserStatsQuery, useGetUserSubmissionsQuery, + useTestSubmissionMutation, } = api diff --git a/src/pages/tasks/TaskFormPage.tsx b/src/pages/tasks/TaskFormPage.tsx index 641de07..809f3c5 100644 --- a/src/pages/tasks/TaskFormPage.tsx +++ b/src/pages/tasks/TaskFormPage.tsx @@ -19,6 +19,7 @@ import { useGetTaskQuery, useCreateTaskMutation, useUpdateTaskMutation, + useTestSubmissionMutation, } from '../../__data__/api/api' import { URLs } from '../../__data__/urls' import { LoadingSpinner } from '../../components/LoadingSpinner' @@ -35,11 +36,15 @@ export const TaskFormPage: React.FC = () => { }) const [createTask, { isLoading: isCreating }] = useCreateTaskMutation() const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation() + const [testSubmission, { isLoading: isTesting }] = useTestSubmissionMutation() const [title, setTitle] = useState('') const [description, setDescription] = useState('') const [hiddenInstructions, setHiddenInstructions] = useState('') const [showDescPreview, setShowDescPreview] = useState(false) + const [testAnswer, setTestAnswer] = useState('') + const [testStatus, setTestStatus] = useState(null) + const [testFeedback, setTestFeedback] = useState(null) useEffect(() => { 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) { return } @@ -309,6 +366,57 @@ export const TaskFormPage: React.FC = () => { )} + {/* Test submission (LLM check) */} + {isEdit && task && ( + + + {t('challenge.admin.tasks.test.title')} + + + {t('challenge.admin.tasks.test.description')} + + + {t('challenge.admin.tasks.test.field.answer')} +