33 KiB
33 KiB
Challenge Service - Руководство для фронтенда
Руководство по интеграции сервиса проверки заданий через LLM в пользовательский интерфейс.
Содержание
- Основные сценарии использования
- Структура данных
- API взаимодействие
- Компоненты UI
- State Management
- Best Practices
Основные сценарии использования
1. Сценарий для студента
┌─────────────────────────────────────────────────────────────┐
│ 1. Вход/Регистрация (nickname) │
│ POST /api/challenge/auth │
│ → Сохранить userId в localStorage/state │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 2. Просмотр доступных цепочек заданий │
│ GET /api/challenge/chains │
│ → Отобразить список с прогрессом │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 3. Выбор задания из цепочки │
│ → Отобразить описание (Markdown) │
│ → Показать историю своих попыток │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 4. Написание решения │
│ → Текстовое поле / Code Editor │
│ → Кнопка "Отправить на проверку" │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 5. Отправка на проверку │
│ POST /api/challenge/submit │
│ → Получить queueId │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 6. Ожидание результата (polling) │
│ GET /api/challenge/check-status/:queueId │
│ → Показать индикатор загрузки + позицию в очереди │
│ → Повторять каждые 2-3 секунды │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 7. Получение результата │
│ ✅ ПРИНЯТО → Поздравление + переход к следующему │
│ ❌ ДОРАБОТКА → Feedback от LLM + возможность повторить │
└─────────────────────────────────────────────────────────────┘
2. Сценарий для администратора
┌─────────────────────────────────────────────────────────────┐
│ 1. Создание заданий │
│ POST /api/challenge/task │
│ → Markdown редактор для описания │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 2. Создание цепочек │
│ POST /api/challenge/chain │
│ → Выбор заданий + порядок │
└────────────────┬────────────────────────────────────────────┘
│
┌────────────────▼────────────────────────────────────────────┐
│ 3. Мониторинг статистики │
│ GET /api/challenge/stats │
│ → Dashboard с метриками │
└─────────────────────────────────────────────────────────────┘
Структура данных
User (Пользователь)
interface ChallengeUser {
_id: string
id: string
nickname: string
createdAt: string // ISO 8601
}
Task (Задание)
interface ChallengeTask {
_id: string
id: string
title: string
description: string // Markdown (видно всем)
hiddenInstructions?: string // Скрытые инструкции для LLM (только для преподавателей)
creator?: object // Данные создателя (только для преподавателей)
createdAt: string
updatedAt: string
}
Важно: Поля hiddenInstructions и creator доступны только пользователям с ролью teacher в Keycloak. При запросе заданий обычными студентами эти поля будут отфильтрованы на сервере.
Chain (Цепочка заданий)
interface ChallengeChain {
_id: string
id: string
name: string
tasks: ChallengeTask[] // Populated
createdAt: string
updatedAt: string
}
Submission (Попытка)
type SubmissionStatus = 'pending' | 'in_progress' | 'accepted' | 'needs_revision'
interface ChallengeSubmission {
_id: string
id: string
user: ChallengeUser | string // Populated или ID
task: ChallengeTask | string // Populated или ID
result: string // Результат пользователя
status: SubmissionStatus
queueId?: string
feedback?: string // Комментарий от LLM
submittedAt: string
checkedAt?: string
attemptNumber: number
}
Queue Status (Статус проверки)
type QueueStatusType = 'waiting' | 'in_progress' | 'completed' | 'error' | 'not_found'
interface QueueStatus {
status: QueueStatusType
submission?: ChallengeSubmission
error?: string
position?: number // Позиция в очереди (если waiting)
}
User Stats (Статистика пользователя)
interface TaskStats {
taskId: string
taskTitle: string
attempts: Array<{
attemptNumber: number
status: SubmissionStatus
submittedAt: string
checkedAt?: string
feedback?: string
}>
totalAttempts: number
status: 'not_attempted' | 'pending' | 'in_progress' | 'completed' | 'needs_revision'
lastAttemptAt: string | null
}
interface ChainStats {
chainId: string
chainName: string
totalTasks: number
completedTasks: number
progress: number // 0-100
}
interface UserStats {
totalTasksAttempted: number
completedTasks: number
inProgressTasks: number
needsRevisionTasks: number
totalSubmissions: number
averageCheckTimeMs: number
taskStats: TaskStats[]
chainStats: ChainStats[]
}
System Stats (Общая статистика)
interface SystemStats {
users: number
tasks: number
chains: number
submissions: {
total: number
accepted: number
rejected: number
pending: number
inProgress: number
}
averageCheckTimeMs: number
queue: {
queueLength: number
waiting: number
inProgress: number
maxConcurrency: number
currentlyProcessing: number
}
}
API взаимодействие
Базовая настройка
const API_BASE_URL = 'http://localhost:8082/api/challenge'
// Универсальная функция для запросов
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ error: any; data: T }> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
return response.json()
}
Примеры использования
1. Аутентификация
async function authenticateUser(nickname: string): Promise<string> {
const { data, error } = await apiRequest<{ ok: boolean; userId: string }>(
'/auth',
{
method: 'POST',
body: JSON.stringify({ nickname }),
}
)
if (error) throw error
// Сохраняем userId
localStorage.setItem('challengeUserId', data.userId)
return data.userId
}
2. Получение цепочек
async function getChains(): Promise<ChallengeChain[]> {
const { data, error } = await apiRequest<ChallengeChain[]>('/chains')
if (error) throw error
return data
}
3. Отправка решения
async function submitSolution(
userId: string,
taskId: string,
result: string
): Promise<{ queueId: string; submissionId: string }> {
const { data, error } = await apiRequest<{
queueId: string
submissionId: string
}>('/submit', {
method: 'POST',
body: JSON.stringify({ userId, taskId, result }),
})
if (error) throw error
return data
}
4. Polling проверки
async function pollCheckStatus(
queueId: string,
onUpdate: (status: QueueStatus) => void,
interval: number = 2000
): Promise<ChallengeSubmission> {
return new Promise((resolve, reject) => {
const checkStatus = async () => {
try {
const { data, error } = await apiRequest<QueueStatus>(
`/check-status/${queueId}`
)
if (error) {
reject(error)
return
}
onUpdate(data)
if (data.status === 'completed' && data.submission) {
resolve(data.submission)
} else if (data.status === 'error') {
reject(new Error(data.error || 'Check failed'))
} else {
// Продолжаем polling
setTimeout(checkStatus, interval)
}
} catch (err) {
reject(err)
}
}
checkStatus()
})
}
5. Получение статистики пользователя
async function getUserStats(userId: string): Promise<UserStats> {
const { data, error } = await apiRequest<UserStats>(`/user/${userId}/stats`)
if (error) throw error
return data
}
Компоненты UI
1. AuthForm - Форма входа
// AuthForm.tsx
import { useState } from 'react'
interface AuthFormProps {
onAuth: (userId: string, nickname: string) => void
}
export function AuthForm({ onAuth }: AuthFormProps) {
const [nickname, setNickname] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const userId = await authenticateUser(nickname)
onAuth(userId, nickname)
} catch (err) {
setError('Ошибка входа. Попробуйте другой nickname.')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Введите ваш nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
minLength={3}
maxLength={50}
required
/>
<button type="submit" disabled={loading}>
{loading ? 'Вход...' : 'Войти'}
</button>
{error && <div className="error">{error}</div>}
</form>
)
}
2. ChainList - Список цепочек
// ChainList.tsx
import { useState, useEffect } from 'react'
interface ChainListProps {
userId: string
onSelectChain: (chain: ChallengeChain) => void
}
export function ChainList({ userId, onSelectChain }: ChainListProps) {
const [chains, setChains] = useState<ChallengeChain[]>([])
const [stats, setStats] = useState<UserStats | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [userId])
const loadData = async () => {
try {
const [chainsData, statsData] = await Promise.all([
getChains(),
getUserStats(userId)
])
setChains(chainsData)
setStats(statsData)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
const getChainProgress = (chainId: string) => {
return stats?.chainStats.find(cs => cs.chainId === chainId)
}
if (loading) return <div>Загрузка...</div>
return (
<div className="chain-list">
{chains.map(chain => {
const progress = getChainProgress(chain.id)
return (
<div key={chain.id} className="chain-card" onClick={() => onSelectChain(chain)}>
<h3>{chain.name}</h3>
<p>{chain.tasks.length} заданий</p>
{progress && (
<div className="progress">
<div className="progress-bar" style={{ width: `${progress.progress}%` }} />
<span>{progress.completedTasks} / {progress.totalTasks}</span>
</div>
)}
</div>
)
})}
</div>
)
}
3. TaskView - Просмотр и решение задания
// TaskView.tsx
import { useState } from 'react'
import ReactMarkdown from 'react-markdown'
interface TaskViewProps {
task: ChallengeTask
userId: string
onComplete: () => void
}
export function TaskView({ task, userId, onComplete }: TaskViewProps) {
const [result, setResult] = useState('')
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async () => {
setSubmitting(true)
try {
const { queueId } = await submitSolution(userId, task.id, result)
// Переходим к экрану проверки
// (см. CheckStatusView)
} catch (err) {
alert('Ошибка отправки')
} finally {
setSubmitting(false)
}
}
return (
<div className="task-view">
<h2>{task.title}</h2>
<div className="task-description">
<ReactMarkdown>{task.description}</ReactMarkdown>
</div>
<div className="solution-editor">
<h3>Ваше решение:</h3>
<textarea
value={result}
onChange={(e) => setResult(e.target.value)}
placeholder="Напишите ваше решение здесь..."
rows={15}
/>
</div>
<button
onClick={handleSubmit}
disabled={!result.trim() || submitting}
>
{submitting ? 'Отправка...' : 'Отправить на проверку'}
</button>
</div>
)
}
4. CheckStatusView - Отслеживание проверки
// CheckStatusView.tsx
import { useState, useEffect } from 'react'
interface CheckStatusViewProps {
queueId: string
onComplete: (submission: ChallengeSubmission) => void
}
export function CheckStatusView({ queueId, onComplete }: CheckStatusViewProps) {
const [status, setStatus] = useState<QueueStatus | null>(null)
const [error, setError] = useState('')
useEffect(() => {
pollCheckStatus(queueId, setStatus)
.then(onComplete)
.catch((err) => setError(err.message))
}, [queueId])
if (error) {
return (
<div className="check-error">
<h3>❌ Ошибка проверки</h3>
<p>{error}</p>
</div>
)
}
if (!status) {
return <div>Инициализация...</div>
}
return (
<div className="check-status">
{status.status === 'waiting' && (
<>
<div className="spinner" />
<h3>⏳ Ожидание в очереди</h3>
{status.position && <p>Позиция в очереди: {status.position}</p>}
</>
)}
{status.status === 'in_progress' && (
<>
<div className="spinner" />
<h3>🔍 Проверяем ваше решение...</h3>
<p>Это может занять несколько секунд</p>
</>
)}
</div>
)
}
5. ResultView - Отображение результата
// ResultView.tsx
interface ResultViewProps {
submission: ChallengeSubmission
onRetry?: () => void
onNext?: () => void
}
export function ResultView({ submission, onRetry, onNext }: ResultViewProps) {
const isAccepted = submission.status === 'accepted'
return (
<div className={`result-view ${isAccepted ? 'accepted' : 'needs-revision'}`}>
<div className="result-icon">
{isAccepted ? '✅' : '❌'}
</div>
<h2>{isAccepted ? 'Задание принято!' : 'Требуется доработка'}</h2>
<div className="feedback">
<h3>Комментарий:</h3>
<p>{submission.feedback}</p>
</div>
<div className="result-meta">
<p>Попытка #{submission.attemptNumber}</p>
<p>Время проверки: {getCheckTime(submission)}</p>
</div>
<div className="actions">
{isAccepted && onNext && (
<button onClick={onNext} className="btn-primary">
Следующее задание →
</button>
)}
{!isAccepted && onRetry && (
<button onClick={onRetry} className="btn-secondary">
Попробовать снова
</button>
)}
</div>
</div>
)
}
function getCheckTime(submission: ChallengeSubmission): string {
if (!submission.checkedAt) return 'N/A'
const submitted = new Date(submission.submittedAt)
const checked = new Date(submission.checkedAt)
const diff = checked.getTime() - submitted.getTime()
return `${Math.round(diff / 1000)} сек`
}
6. UserStatsView - Статистика пользователя
// UserStatsView.tsx
import { useState, useEffect } from 'react'
interface UserStatsViewProps {
userId: string
}
export function UserStatsView({ userId }: UserStatsViewProps) {
const [stats, setStats] = useState<UserStats | null>(null)
useEffect(() => {
getUserStats(userId).then(setStats)
}, [userId])
if (!stats) return <div>Загрузка...</div>
return (
<div className="user-stats">
<h2>Ваша статистика</h2>
<div className="stats-overview">
<div className="stat-card">
<h3>{stats.completedTasks}</h3>
<p>Задания выполнено</p>
</div>
<div className="stat-card">
<h3>{stats.totalTasksAttempted}</h3>
<p>Всего попыток</p>
</div>
<div className="stat-card">
<h3>{Math.round(stats.averageCheckTimeMs / 1000)}с</h3>
<p>Среднее время проверки</p>
</div>
</div>
<h3>Прогресс по цепочкам</h3>
{stats.chainStats.map(chain => (
<div key={chain.chainId} className="chain-progress">
<h4>{chain.chainName}</h4>
<div className="progress-bar">
<div style={{ width: `${chain.progress}%` }} />
</div>
<span>{chain.completedTasks} / {chain.totalTasks}</span>
</div>
))}
<h3>Детали по заданиям</h3>
{stats.taskStats.map(taskStat => (
<div key={taskStat.taskId} className="task-stat">
<h4>{taskStat.taskTitle}</h4>
<p>Статус: {getStatusLabel(taskStat.status)}</p>
<p>Попыток: {taskStat.totalAttempts}</p>
</div>
))}
</div>
)
}
function getStatusLabel(status: string): string {
const labels: Record<string, string> = {
'not_attempted': 'Не начато',
'pending': 'Ожидает проверки',
'in_progress': 'Проверяется',
'completed': 'Завершено',
'needs_revision': 'Требует доработки'
}
return labels[status] || status
}
7. AdminDashboard - Панель администратора
// AdminDashboard.tsx
import { useState, useEffect } from 'react'
export function AdminDashboard() {
const [stats, setStats] = useState<SystemStats | null>(null)
useEffect(() => {
const loadStats = async () => {
const { data } = await apiRequest<SystemStats>('/stats')
setStats(data)
}
loadStats()
const interval = setInterval(loadStats, 10000) // Обновляем каждые 10 сек
return () => clearInterval(interval)
}, [])
if (!stats) return <div>Загрузка...</div>
return (
<div className="admin-dashboard">
<h2>Панель администратора</h2>
<div className="dashboard-grid">
<div className="dashboard-card">
<h3>Пользователи</h3>
<div className="big-number">{stats.users}</div>
</div>
<div className="dashboard-card">
<h3>Задания</h3>
<div className="big-number">{stats.tasks}</div>
</div>
<div className="dashboard-card">
<h3>Цепочки</h3>
<div className="big-number">{stats.chains}</div>
</div>
<div className="dashboard-card">
<h3>Всего проверок</h3>
<div className="big-number">{stats.submissions.total}</div>
</div>
</div>
<div className="submissions-chart">
<h3>Статистика проверок</h3>
<div className="chart-bars">
<div className="bar accepted" style={{ height: `${(stats.submissions.accepted / stats.submissions.total) * 100}%` }}>
<span>Принято: {stats.submissions.accepted}</span>
</div>
<div className="bar rejected" style={{ height: `${(stats.submissions.rejected / stats.submissions.total) * 100}%` }}>
<span>Отклонено: {stats.submissions.rejected}</span>
</div>
<div className="bar pending" style={{ height: `${(stats.submissions.pending / stats.submissions.total) * 100}%` }}>
<span>Ожидают: {stats.submissions.pending}</span>
</div>
</div>
</div>
<div className="queue-status">
<h3>Статус очереди</h3>
<div className="queue-info">
<p>🔄 В обработке: {stats.queue.currentlyProcessing} / {stats.queue.maxConcurrency}</p>
<p>⏳ В ожидании: {stats.queue.waiting}</p>
<p>📊 Всего в очереди: {stats.queue.queueLength}</p>
</div>
</div>
<div className="avg-time">
<h3>Среднее время проверки</h3>
<div className="big-number">{Math.round(stats.averageCheckTimeMs / 1000)}с</div>
</div>
</div>
)
}
State Management
React Context пример
// ChallengeContext.tsx
import { createContext, useContext, useState, useEffect } from 'react'
interface ChallengeContextType {
userId: string | null
nickname: string | null
login: (nickname: string) => Promise<void>
logout: () => void
stats: UserStats | null
refreshStats: () => Promise<void>
}
const ChallengeContext = createContext<ChallengeContextType | undefined>(undefined)
export function ChallengeProvider({ children }: { children: React.ReactNode }) {
const [userId, setUserId] = useState<string | null>(
localStorage.getItem('challengeUserId')
)
const [nickname, setNickname] = useState<string | null>(
localStorage.getItem('challengeNickname')
)
const [stats, setStats] = useState<UserStats | null>(null)
const login = async (nickname: string) => {
const userId = await authenticateUser(nickname)
setUserId(userId)
setNickname(nickname)
localStorage.setItem('challengeNickname', nickname)
}
const logout = () => {
setUserId(null)
setNickname(null)
setStats(null)
localStorage.removeItem('challengeUserId')
localStorage.removeItem('challengeNickname')
}
const refreshStats = async () => {
if (userId) {
const userStats = await getUserStats(userId)
setStats(userStats)
}
}
useEffect(() => {
if (userId) {
refreshStats()
}
}, [userId])
return (
<ChallengeContext.Provider value={{
userId,
nickname,
login,
logout,
stats,
refreshStats
}}>
{children}
</ChallengeContext.Provider>
)
}
export function useChallenge() {
const context = useContext(ChallengeContext)
if (!context) {
throw new Error('useChallenge must be used within ChallengeProvider')
}
return context
}
Best Practices
1. Polling оптимизация
// Используйте exponential backoff для polling
class PollingManager {
private intervalId: number | null = null
private currentDelay = 2000 // Начинаем с 2 секунд
private maxDelay = 10000 // Максимум 10 секунд
start(callback: () => Promise<boolean>) {
const poll = async () => {
const shouldContinue = await callback()
if (shouldContinue) {
// Увеличиваем delay
this.currentDelay = Math.min(this.currentDelay * 1.5, this.maxDelay)
this.intervalId = window.setTimeout(poll, this.currentDelay)
}
}
poll()
}
stop() {
if (this.intervalId) {
clearTimeout(this.intervalId)
this.intervalId = null
}
}
}
2. Обработка ошибок
// Создайте централизованный обработчик ошибок
class ChallengeAPIError extends Error {
constructor(
public message: string,
public statusCode?: number,
public details?: any
) {
super(message)
}
}
async function handleAPIError(response: Response) {
const data = await response.json()
if (data.error) {
throw new ChallengeAPIError(
data.error.message || 'Unknown error',
response.status,
data.error
)
}
return data
}
3. Кеширование
// Кешируйте редко меняющиеся данные
class ChallengeCache {
private cache = new Map<string, { data: any; expires: number }>()
set(key: string, data: any, ttl: number = 60000) {
this.cache.set(key, {
data,
expires: Date.now() + ttl
})
}
get(key: string): any | null {
const cached = this.cache.get(key)
if (!cached) return null
if (Date.now() > cached.expires) {
this.cache.delete(key)
return null
}
return cached.data
}
}
const cache = new ChallengeCache()
async function getChainsWithCache(): Promise<ChallengeChain[]> {
const cached = cache.get('chains')
if (cached) return cached
const chains = await getChains()
cache.set('chains', chains, 5 * 60 * 1000) // 5 минут
return chains
}
4. Оптимистичные обновления
// Показывайте изменения сразу, до ответа от сервера
async function optimisticSubmit(
task: ChallengeTask,
result: string,
updateUI: (status: 'submitting' | 'waiting' | 'checking') => void
) {
updateUI('submitting')
try {
const { queueId } = await submitSolution(userId, task.id, result)
updateUI('waiting')
const submission = await pollCheckStatus(queueId, (status) => {
if (status.status === 'in_progress') {
updateUI('checking')
}
})
return submission
} catch (err) {
// Откатываем UI если ошибка
throw err
}
}
5. Accessibility
// Добавляйте ARIA атрибуты
<div
role="alert"
aria-live="polite"
aria-atomic="true"
>
{status === 'waiting' && 'Ожидание в очереди...'}
{status === 'in_progress' && 'Проверка решения...'}
</div>
// Используйте semantic HTML
<button
aria-label="Отправить решение на проверку"
disabled={submitting}
aria-busy={submitting}
>
Отправить
</button>
6. Offline support
// Сохраняйте незавершенные решения локально
function saveDraft(taskId: string, result: string) {
localStorage.setItem(`draft_${taskId}`, result)
}
function loadDraft(taskId: string): string | null {
return localStorage.getItem(`draft_${taskId}`)
}
function clearDraft(taskId: string) {
localStorage.removeItem(`draft_${taskId}`)
}
Примеры интеграции
React + TypeScript
Полный пример приложения доступен в репозитории: /examples/react-challenge-app
Vue.js
// composition API
import { ref, onMounted } from 'vue'
export function useChallengeSubmission(taskId: string) {
const result = ref('')
const submitting = ref(false)
const checkStatus = ref<QueueStatus | null>(null)
const submit = async () => {
submitting.value = true
try {
const { queueId } = await submitSolution(userId, taskId, result.value)
await pollCheckStatus(queueId, (status) => {
checkStatus.value = status
})
} finally {
submitting.value = false
}
}
return {
result,
submitting,
checkStatus,
submit
}
}
Заключение
Этот сервис предоставляет гибкий API для создания различных пользовательских интерфейсов. Основные принципы:
- Polling для отслеживания - используйте разумные интервалы
- Оптимистичные обновления - для лучшего UX
- Кеширование - для производительности
- Обработка ошибок - для надежности
- Accessibility - для всех пользователей
Используйте предоставленные компоненты как основу и адаптируйте под ваш дизайн-систему!