Browse Source

Спринт 4: AI-помощник на базе DeepSeek

- Страница /settings: ввод и проверка API ключа DeepSeek
- POST /api/llm/generate — генерация вопросов по названию теста
- POST /api/llm/improve — улучшение формулировки вопроса + ответов (модал с галочками)
- POST /api/llm/distractors — генерация дистракторов
- POST /api/llm/review — рецензия теста + кнопка «Предложить вариант»
- POST /api/llm/improve_all — улучшение всего теста с постатейным сравнением
- Миграция 004: таблица settings (key-value)
- Шапка приложения с навигацией на /settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev-new-design-page-createtest
Aleksey Razorvin 2 months ago
parent
commit
9a0b3ba92c
  1. 37
      DOC/СПРИНТЫ.md
  2. 101
      DOC/ШАГИ/ШАГ_2026-03-21_011.md
  3. 78
      DOC/ШАГИ/ШАГ_2026-03-21_012.md
  4. 4
      README.md
  5. 2
      backend/alembic/env.py
  6. 33
      backend/alembic/versions/004_settings.py
  7. 129
      backend/app/api/llm.py
  8. 34
      backend/app/api/settings.py
  9. 4
      backend/app/main.py
  10. 17
      backend/app/models/setting.py
  11. 14
      backend/app/schemas/setting.py
  12. 0
      backend/app/services/__init__.py
  13. 203
      backend/app/services/llm.py
  14. 1
      backend/requirements.txt
  15. 45
      frontend/src/App.tsx
  16. 44
      frontend/src/api/llm.ts
  17. 14
      frontend/src/api/settings.ts
  18. 620
      frontend/src/components/TestForm/index.tsx
  19. 124
      frontend/src/pages/Settings/index.tsx

37
DOC/СПРИНТЫ.md

@ -102,34 +102,41 @@
--- ---
## Спринт 4 — AI-помощник (DeepSeek) ## Спринт 4 — AI-помощник (DeepSeek)
**Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек. **Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек.
**Статус:** Завершён и протестирован вручную в браузере.
### Страница настроек (`/settings`) ### Страница настроек (`/settings`)
- [ ] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`) - [x] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`)
- [ ] Миграция `004` - [x] Миграция `004`
- [ ] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}` - [x] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}`
- [ ] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek) - [x] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek)
- [ ] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение» - [x] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение»
### AI-функции в форме создания/редактирования теста ### AI-функции в форме создания/редактирования теста
- [ ] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме - [x] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме
- [ ] API: `POST /api/llm/improve` — улучшить формулировку вопроса - [x] API: `POST /api/llm/improve` — улучшить формулировку вопроса
- [ ] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу - [x] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу
- [ ] API: `POST /api/llm/review` — проверить качество всего теста - [x] API: `POST /api/llm/review` — проверить качество всего теста
### Интеграция в UI ### Интеграция в UI
- [ ] Кнопка «Сгенерировать с AI» на странице создания теста — вводишь тему, получаешь готовый набор вопросов - [x] Кнопка «Сгенерировать с AI» над списком вопросов — ввод темы → превью → «Применить все вопросы»
- [ ] Кнопка «✨» рядом с каждым вопросом — улучшить формулировку - [x] Кнопка «Улучшить» в шапке каждого вопроса — заменяет формулировку AI-версией
- [ ] Кнопка «+ Дистракторы» рядом с каждым вопросом — дополнить неправильные варианты - [x] Кнопка «Дистракторы» в шапке каждого вопроса — добавляет 3 новых неправильных варианта
- [ ] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации - [x] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации в модале
- [ ] Ссылка на страницу `/settings` в шапке приложения - [x] Ссылка «Настройки» в шапке приложения (новый Layout с AppHeader)
### Технические детали ### Технические детали
- DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com` - DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com`
- Модель: `deepseek-chat` - Модель: `deepseek-chat`
- Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит - Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит
- `response_format={"type": "json_object"}` для generate, distractors, improve, improve_all — гарантирует структурированный ответ
### Доработки после тестирования
- [x] «Сгенерировать с AI»: убран вопрос про тему — используется название теста; кнопка задизаблена пока название не заполнено
- [x] «Улучшить»: открывает модал с постатейным сравнением (вопрос + каждый ответ) и галочками вместо прямой замены
- [x] «Проверить тест»: добавлена кнопка «Предложить вариант» — вызывает `POST /api/llm/improve_all`, показывает сравнение всего теста с галочками
--- ---

101
DOC/ШАГИ/ШАГ_2026-03-21_011.md

@ -0,0 +1,101 @@
# ШАГ 011 — Спринт 4: AI-помощник (DeepSeek)
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос
> запускам спринт 4
---
## Реализовано
Интеграция с DeepSeek LLM при создании и редактировании тестов. Страница настроек для управления API ключом.
---
## Новые файлы
```
backend/app/models/setting.py ← модель Setting (key-value)
backend/app/schemas/setting.py ← SettingOut, SettingUpdate
backend/app/services/__init__.py ← пакет services
backend/app/services/llm.py ← DeepSeek клиент (все 4 функции)
backend/app/api/settings.py ← GET/PUT /api/settings/{key}
backend/app/api/llm.py ← POST /api/llm/check|generate|improve|distractors|review
backend/alembic/versions/004_settings.py ← миграция: таблица settings
frontend/src/api/settings.ts ← API клиент настроек
frontend/src/api/llm.ts ← API клиент LLM
frontend/src/pages/Settings/index.tsx ← страница /settings
```
## Изменённые файлы
```
backend/app/main.py ← зарегистрированы роутеры settings и llm
backend/alembic/env.py ← импорт модели setting
backend/requirements.txt ← добавлен openai==1.57.0
frontend/src/components/TestForm/index.tsx ← добавлены AI-кнопки
frontend/src/App.tsx ← Layout с шапкой, роут /settings
```
---
## API эндпоинты (новые)
| Метод | URL | Описание |
|-------|-----|----------|
| GET | `/api/settings/{key}` | Получить значение настройки |
| PUT | `/api/settings/{key}` | Сохранить значение настройки |
| POST | `/api/llm/check` | Проверить подключение к DeepSeek |
| POST | `/api/llm/generate` | Сгенерировать вопросы по теме |
| POST | `/api/llm/improve` | Улучшить формулировку вопроса |
| POST | `/api/llm/distractors` | Сгенерировать дистракторы |
| POST | `/api/llm/review` | Проверить качество всего теста |
---
## Схема БД (новое)
```
settings
key VARCHAR(100) PK
value TEXT nullable
updated_at TIMESTAMP auto-updated
```
---
## AI-функции в форме теста
| Кнопка | Расположение | Действие |
|--------|-------------|---------|
| «Сгенерировать с AI» | Над списком вопросов | Открывает модал → ввод темы → превью → «Применить все вопросы» |
| «Проверить тест» | Над списком вопросов | Открывает модал с рекомендациями AI по всему тесту |
| «Улучшить» | В шапке каждого вопроса | Заменяет текст вопроса улучшенной AI-формулировкой |
| «Дистракторы» | В шапке каждого вопроса | Добавляет 3 новых неправильных варианта к вопросу |
---
## Технические детали
- DeepSeek API совместим с OpenAI SDK: `AsyncOpenAI(base_url="https://api.deepseek.com")`
- Модель: `deepseek-chat`
- `response_format={"type": "json_object"}` для generate и distractors — гарантирует JSON-ответ
- API ключ хранится в таблице `settings` с ключом `deepseek_api_key`; фронт ключ не видит
- Шапка приложения: новый `Layout` с `AppHeader` — ссылка «Настройки» в правом углу
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX

78
DOC/ШАГИ/ШАГ_2026-03-21_012.md

@ -0,0 +1,78 @@
# ШАГ 012 — Спринт 4: Доработки после тестирования
**Дата:** 2026-03-21
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
---
## Запрос (серия правок после тестирования)
> 1. «Сгенерировать с AI» не должен спрашивать тему — использовать название теста
> 2. «Улучшить» должен показывать сравнение старого и нового с галочками, а не затирать текст
> 3. «Проверить тест» → добавить кнопку «Предложить вариант» с полным сравнением всего теста
---
## Доработки
### 1. Кнопка «Сгенерировать с AI»
**Было:** при нажатии открывался модал с полем ввода темы.
**Стало:**
- Кнопка задизаблена, пока не заполнено поле «Название теста» (`shouldUpdate` на поле `title`)
- При нажатии сразу берёт название теста как тему и запускает генерацию
- Открывается модал с анимацией загрузки → превью вопросов → «Применить» / «Сгенерировать заново»
### 2. Кнопка «Улучшить» в карточке вопроса
**Было:** заменяла текст вопроса новой формулировкой без предупреждения.
**Стало:**
- Открывается модал с двумя колонками: текущая формулировка и предложение AI
- Изменения разбиты на позиции: текст вопроса + каждый вариант ответа отдельно
- Чекбокс «Применить» у каждой позиции
- Кнопка «Применить выбранные» — применяет только отмеченные пункты
**Изменения в бэкенде:**
- `improve_question(db, question, answers)` — теперь принимает список ответов и возвращает JSON `{question, answers[]}`
- `POST /api/llm/improve``ImproveRequest` добавлено поле `answers`, `ImproveResponse` теперь `{improved_question, improved_answers[]}`
### 3. Кнопка «Предложить вариант» в модале «Проверить тест»
**Новая кнопка** появляется после получения рекомендаций AI.
**Поведение:**
- При нажатии вызывает новый `POST /api/llm/improve_all`
- Модал переключается в режим сравнения: весь тест постранично
- Для каждого вопроса: текущий vs AI-предложение + чекбокс
- Для каждого варианта ответа: текущий vs AI-предложение + чекбокс
- Правильные ответы помечены `(правильный ✓)`
- Кнопки: «Применить выбранные» / «← К рекомендациям» / «Закрыть»
**Новые файлы/функции бэкенда:**
- `improve_all(db, title, questions)` в `services/llm.py`
- `POST /api/llm/improve_all` в `api/llm.py`
---
## Изменённые файлы
```
backend/app/services/llm.py ← improve_question принимает answers; новая функция improve_all
backend/app/api/llm.py ← обновлён ImproveRequest/ImproveResponse; новый /improve_all
frontend/src/api/llm.ts ← обновлена сигнатура improve; новый метод improveAll
frontend/src/components/TestForm/ ← все три доработки UI
```
---
## Следующие шаги
- [x] Спринт 1: Инфраструктура + Создание тестов
- [x] Спринт 2: Прохождение теста + результат
- [x] Спринт 3: Редактирование + версионность
- [x] Спринт 4: AI-помощник (DeepSeek)
- [ ] Спринт 5: Трекер результатов
- [ ] Спринт 6: Авторизация и роли
- [ ] Спринт 7: Уведомления в MAX

4
README.md

@ -46,7 +46,7 @@
| **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ | | **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ |
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ | | **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
| **3** | Редактирование тестов + версионность | ✅ | | **3** | Редактирование тестов + версионность | ✅ |
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | | | **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | |
| **5** | Трекер результатов | ⬜ | | **5** | Трекер результатов | ⬜ |
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | | **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
| **7** | Уведомления в MAX | ⬜ | | **7** | Уведомления в MAX | ⬜ |
@ -57,7 +57,7 @@
| Файл | Содержание | | Файл | Содержание |
|------|-----------| |------|-----------|
| `DOC/ТЗ.md` | Техническое задание (v1.1) | | `DOC/ТЗ.md` | Техническое задание (v1.2) |
| `DOC/СТЕК.md` | Технологический стек с обоснованием | | `DOC/СТЕК.md` | Технологический стек с обоснованием |
| `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами | | `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами |
| `DOC/ШАГИ/` | История разработки шаг за шагом | | `DOC/ШАГИ/` | История разработки шаг за шагом |

2
backend/alembic/env.py

@ -15,7 +15,7 @@ if config.config_file_name is not None:
# Берём DATABASE_URL из настроек приложения # Берём DATABASE_URL из настроек приложения
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models import attempt, test # noqa: F401 — импортируем модели, чтобы Alembic их видел from app.models import attempt, setting, test # noqa: F401 — импортируем модели, чтобы Alembic их видел
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)

33
backend/alembic/versions/004_settings.py

@ -0,0 +1,33 @@
"""004_settings
Revision ID: 004
Revises: 003
Create Date: 2026-03-21
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"settings",
sa.Column("key", sa.String(100), primary_key=True),
sa.Column("value", sa.Text(), nullable=True),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("settings")

129
backend/app/api/llm.py

@ -0,0 +1,129 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services import llm as llm_service
router = APIRouter(tags=["llm"])
class CheckResponse(BaseModel):
ok: bool
message: str
class GenerateRequest(BaseModel):
topic: str
count: int = 7
class GenerateResponse(BaseModel):
questions: list[dict]
class ImproveRequest(BaseModel):
question: str
answers: list[str]
class ImproveResponse(BaseModel):
improved_question: str
improved_answers: list[str]
class DistractorsRequest(BaseModel):
question: str
answers: list[str]
class DistractorsResponse(BaseModel):
distractors: list[str]
class ReviewRequest(BaseModel):
title: str
questions: list[dict]
class ReviewResponse(BaseModel):
review: str
class ImproveAllRequest(BaseModel):
title: str
questions: list[dict]
class ImproveAllResponse(BaseModel):
questions: list[dict]
@router.post("/api/llm/check", response_model=CheckResponse)
async def check_connection(db: AsyncSession = Depends(get_db)):
try:
result = await llm_service.check_connection(db)
return {"ok": True, "message": f"Подключение успешно: {result}"}
except ValueError as e:
return {"ok": False, "message": str(e)}
except Exception as e:
return {"ok": False, "message": f"Ошибка подключения: {str(e)}"}
@router.post("/api/llm/generate", response_model=GenerateResponse)
async def generate_questions(req: GenerateRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.generate_questions(db, req.topic, req.count)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve", response_model=ImproveResponse)
async def improve_question(req: ImproveRequest, db: AsyncSession = Depends(get_db)):
try:
data = await llm_service.improve_question(db, req.question, req.answers)
return {"improved_question": data["question"], "improved_answers": data["answers"]}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/distractors", response_model=DistractorsResponse)
async def generate_distractors(
req: DistractorsRequest, db: AsyncSession = Depends(get_db)
):
try:
distractors = await llm_service.generate_distractors(
db, req.question, req.answers
)
return {"distractors": distractors}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/review", response_model=ReviewResponse)
async def review_test(req: ReviewRequest, db: AsyncSession = Depends(get_db)):
try:
review = await llm_service.review_test(db, req.title, req.questions)
return {"review": review}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")
@router.post("/api/llm/improve_all", response_model=ImproveAllResponse)
async def improve_all(req: ImproveAllRequest, db: AsyncSession = Depends(get_db)):
try:
questions = await llm_service.improve_all(db, req.title, req.questions)
return {"questions": questions}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Ошибка AI: {str(e)}")

34
backend/app/api/settings.py

@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.setting import Setting
from app.schemas.setting import SettingOut, SettingUpdate
router = APIRouter(tags=["settings"])
@router.get("/api/settings/{key}", response_model=SettingOut)
async def get_setting(key: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if setting is None:
return SettingOut(key=key, value=None)
return setting
@router.put("/api/settings/{key}", response_model=SettingOut)
async def update_setting(
key: str, data: SettingUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Setting).where(Setting.key == key))
setting = result.scalar_one_or_none()
if setting is None:
setting = Setting(key=key, value=data.value)
db.add(setting)
else:
setting.value = data.value
await db.commit()
await db.refresh(setting)
return setting

4
backend/app/main.py

@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api import attempts, tests from app.api import attempts, llm, settings, tests
app = FastAPI( app = FastAPI(
title="QA Test App API", title="QA Test App API",
@ -20,6 +20,8 @@ app.add_middleware(
app.include_router(tests.router) app.include_router(tests.router)
app.include_router(attempts.router) app.include_router(attempts.router)
app.include_router(settings.router)
app.include_router(llm.router)
@app.get("/api/health") @app.get("/api/health")

17
backend/app/models/setting.py

@ -0,0 +1,17 @@
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from app.database import Base
class Setting(Base):
__tablename__ = "settings"
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)

14
backend/app/schemas/setting.py

@ -0,0 +1,14 @@
from typing import Optional
from pydantic import BaseModel
class SettingOut(BaseModel):
key: str
value: Optional[str]
model_config = {"from_attributes": True}
class SettingUpdate(BaseModel):
value: Optional[str] = None

0
backend/app/services/__init__.py

203
backend/app/services/llm.py

@ -0,0 +1,203 @@
import json
from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.setting import Setting
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
DEEPSEEK_MODEL = "deepseek-chat"
async def _get_api_key(db: AsyncSession) -> str:
result = await db.execute(select(Setting).where(Setting.key == "deepseek_api_key"))
setting = result.scalar_one_or_none()
if not setting or not setting.value:
raise ValueError("API ключ DeepSeek не настроен. Перейдите в Настройки.")
return setting.value
def _client(api_key: str) -> AsyncOpenAI:
return AsyncOpenAI(api_key=api_key, base_url=DEEPSEEK_BASE_URL)
async def check_connection(db: AsyncSession) -> str:
api_key = await _get_api_key(db)
client = _client(api_key)
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": "Ответь одним словом: работает"}],
max_tokens=10,
)
return response.choices[0].message.content.strip()
async def generate_questions(db: AsyncSession, topic: str, count: int = 7) -> list[dict]:
api_key = await _get_api_key(db)
client = _client(api_key)
prompt = f"""Сгенерируй {count} вопросов для теста по теме: "{topic}".
Верни ТОЛЬКО JSON без пояснений в следующем формате:
{{
"questions": [
{{
"text": "Текст вопроса",
"answers": [
{{"text": "Вариант 1", "is_correct": true}},
{{"text": "Вариант 2", "is_correct": false}},
{{"text": "Вариант 3", "is_correct": false}}
]
}}
]
}}
Требования:
- Минимум 3 варианта ответа на каждый вопрос
- Ровно один правильный ответ на каждый вопрос
- Вопросы должны проверять практические знания по теме
- Варианты ответов должны быть правдоподобными"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=3000,
)
data = json.loads(response.choices[0].message.content)
return data["questions"]
async def improve_question(
db: AsyncSession, question: str, answers: list[str]
) -> dict:
api_key = await _get_api_key(db)
client = _client(api_key)
answers_str = "\n".join(f"{i + 1}. {a}" for i, a in enumerate(answers))
prompt = f"""Улучши формулировки вопроса и вариантов ответов для теста. Сделай их более чёткими, однозначными и профессиональными.
Вопрос: {question}
Варианты ответов (верни в том же порядке и том же количестве):
{answers_str}
Верни ТОЛЬКО JSON без пояснений:
{{
"question": "улучшенный текст вопроса",
"answers": ["улучшенный вариант 1", "улучшенный вариант 2", ...]
}}"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=600,
)
return json.loads(response.choices[0].message.content)
async def generate_distractors(
db: AsyncSession, question: str, existing_answers: list[str]
) -> list[str]:
api_key = await _get_api_key(db)
client = _client(api_key)
existing_str = "\n".join(f"- {a}" for a in existing_answers)
prompt = f"""Для вопроса теста сгенерируй 3 правдоподобных неправильных варианта ответа (дистракторы).
Вопрос: {question}
Уже существующие варианты ответов:
{existing_str}
Верни ТОЛЬКО JSON без пояснений:
{{"distractors": ["Вариант 1", "Вариант 2", "Вариант 3"]}}
Требования:
- Дистракторы должны быть правдоподобными, но неправильными
- Не повторяй уже существующие варианты
- Дистракторы должны быть сопоставимы по длине с существующими вариантами"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=400,
)
data = json.loads(response.choices[0].message.content)
return data["distractors"]
async def review_test(db: AsyncSession, title: str, questions: list[dict]) -> str:
api_key = await _get_api_key(db)
client = _client(api_key)
questions_str = ""
for i, q in enumerate(questions, 1):
questions_str += f"\nВопрос {i}: {q.get('text', '')}\n"
for a in q.get("answers", []):
marker = "" if a.get("is_correct") else ""
questions_str += f" {marker} {a.get('text', '')}\n"
prompt = f"""Проанализируй тест и дай рекомендации по улучшению его качества.
Название теста: {title}
Вопросы:
{questions_str}
Оцени по следующим критериям:
1. Качество и чёткость формулировок вопросов
2. Качество вариантов ответов (правдоподобность дистракторов)
3. Охват темы и разнообразие вопросов
4. Конкретные рекомендации по улучшению
Отвечай на русском языке, структурированно."""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=1500,
)
return response.choices[0].message.content.strip()
async def improve_all(
db: AsyncSession, title: str, questions: list[dict]
) -> list[dict]:
api_key = await _get_api_key(db)
client = _client(api_key)
questions_str = ""
for i, q in enumerate(questions, 1):
questions_str += f"\nВопрос {i}: {q.get('text', '')}\n"
for j, a in enumerate(q.get("answers", []), 1):
questions_str += f" {j}. {a.get('text', '') if isinstance(a, dict) else a}\n"
prompt = f"""Улучши формулировки всех вопросов и вариантов ответов в тесте. Сделай их более чёткими, однозначными и профессиональными.
Название теста: {title}
Вопросы:
{questions_str}
Верни ТОЛЬКО JSON. Для каждого вопроса улучшенную формулировку и все варианты ответов в том же порядке и том же количестве:
{{
"questions": [
{{
"question": "улучшенный текст вопроса 1",
"answers": ["улучшенный вариант 1", "улучшенный вариант 2", "..."]
}}
]
}}"""
response = await client.chat.completions.create(
model=DEEPSEEK_MODEL,
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
max_tokens=4000,
)
data = json.loads(response.choices[0].message.content)
return data["questions"]

1
backend/requirements.txt

@ -5,3 +5,4 @@ asyncpg==0.29.0
alembic==1.13.3 alembic==1.13.3
pydantic==2.9.2 pydantic==2.9.2
pydantic-settings==2.5.2 pydantic-settings==2.5.2
openai==1.57.0

45
frontend/src/App.tsx

@ -1,22 +1,60 @@
import { SettingOutlined } from '@ant-design/icons'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ConfigProvider } from 'antd' import { Button, ConfigProvider, Layout } from 'antd'
import ruRU from 'antd/locale/ru_RU' import ruRU from 'antd/locale/ru_RU'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
import AttemptResult from './pages/AttemptResult' import AttemptResult from './pages/AttemptResult'
import Settings from './pages/Settings'
import TestCreate from './pages/TestCreate' import TestCreate from './pages/TestCreate'
import TestDetail from './pages/TestDetail' import TestDetail from './pages/TestDetail'
import TestEdit from './pages/TestEdit' import TestEdit from './pages/TestEdit'
import TestList from './pages/TestList' import TestList from './pages/TestList'
import TestTake from './pages/TestTake' import TestTake from './pages/TestTake'
const { Header, Content } = Layout
const queryClient = new QueryClient() const queryClient = new QueryClient()
function AppHeader() {
const navigate = useNavigate()
return (
<Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
background: '#fff',
borderBottom: '1px solid #f0f0f0',
padding: '0 24px',
}}
>
<span
style={{ fontWeight: 700, fontSize: 16, cursor: 'pointer' }}
onClick={() => navigate('/')}
>
QA Test App
</span>
<Button
icon={<SettingOutlined />}
type="text"
onClick={() => navigate('/settings')}
title="Настройки"
>
Настройки
</Button>
</Header>
)
}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfigProvider locale={ruRU}> <ConfigProvider locale={ruRU}>
<BrowserRouter> <BrowserRouter>
<Layout style={{ minHeight: '100vh', background: '#f5f5f5' }}>
<AppHeader />
<Content>
<Routes> <Routes>
<Route path="/" element={<TestList />} /> <Route path="/" element={<TestList />} />
<Route path="/tests/create" element={<TestCreate />} /> <Route path="/tests/create" element={<TestCreate />} />
@ -24,7 +62,10 @@ export default function App() {
<Route path="/tests/:id/edit" element={<TestEdit />} /> <Route path="/tests/:id/edit" element={<TestEdit />} />
<Route path="/tests/:testId/take" element={<TestTake />} /> <Route path="/tests/:testId/take" element={<TestTake />} />
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} /> <Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
<Route path="/settings" element={<Settings />} />
</Routes> </Routes>
</Content>
</Layout>
</BrowserRouter> </BrowserRouter>
</ConfigProvider> </ConfigProvider>
</QueryClientProvider> </QueryClientProvider>

44
frontend/src/api/llm.ts

@ -0,0 +1,44 @@
import axios from 'axios'
export interface LLMQuestion {
text: string
answers: { text: string; is_correct: boolean }[]
}
const llmApi = {
check: () =>
axios.post<{ ok: boolean; message: string }>('/api/llm/check').then((r) => r.data),
generate: (topic: string, count = 7) =>
axios
.post<{ questions: LLMQuestion[] }>('/api/llm/generate', { topic, count })
.then((r) => r.data),
improve: (question: string, answers: string[]) =>
axios
.post<{ improved_question: string; improved_answers: string[] }>('/api/llm/improve', {
question,
answers,
})
.then((r) => r.data),
distractors: (question: string, answers: string[]) =>
axios
.post<{ distractors: string[] }>('/api/llm/distractors', { question, answers })
.then((r) => r.data),
review: (title: string, questions: object[]) =>
axios
.post<{ review: string }>('/api/llm/review', { title, questions })
.then((r) => r.data),
improveAll: (title: string, questions: object[]) =>
axios
.post<{ questions: { question: string; answers: string[] }[] }>('/api/llm/improve_all', {
title,
questions,
})
.then((r) => r.data),
}
export default llmApi

14
frontend/src/api/settings.ts

@ -0,0 +1,14 @@
import axios from 'axios'
export interface Setting {
key: string
value: string | null
}
const settingsApi = {
get: (key: string) => axios.get<Setting>(`/api/settings/${key}`).then((r) => r.data),
update: (key: string, value: string | null) =>
axios.put<Setting>(`/api/settings/${key}`, { value }).then((r) => r.data),
}
export default settingsApi

620
frontend/src/components/TestForm/index.tsx

@ -1,4 +1,10 @@
import { ArrowLeftOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons' import {
ArrowLeftOutlined,
MinusCircleOutlined,
PlusOutlined,
RobotOutlined,
StarOutlined,
} from '@ant-design/icons'
import { import {
Button, Button,
Card, Card,
@ -6,13 +12,17 @@ import {
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Space, Space,
Switch, Switch,
Typography, Typography,
notification,
} from 'antd' } from 'antd'
import { useState } from 'react'
import llmApi, { LLMQuestion } from '../../api/llm'
const { Title } = Typography const { Title, Text, Paragraph } = Typography
const EMPTY_ANSWER = { text: '', is_correct: false } const EMPTY_ANSWER = { text: '', is_correct: false }
const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] } const EMPTY_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] }
@ -49,23 +59,247 @@ export default function TestForm({
backLabel = 'Назад', backLabel = 'Назад',
}: TestFormProps) { }: TestFormProps) {
const [form] = Form.useForm<TestFormValues>() const [form] = Form.useForm<TestFormValues>()
const [notifApi, contextHolder] = notification.useNotification()
// Generate state
const [generateLoading, setGenerateLoading] = useState(false)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewQuestions, setPreviewQuestions] = useState<LLMQuestion[] | null>(null)
// Improve modal state
const [improveState, setImproveState] = useState<{
open: boolean
qIndex: number | null
loading: boolean
original: { text: string; answers: { text: string; is_correct: boolean }[] } | null
improved: { question: string; answers: string[] } | null
applyQuestion: boolean
applyAnswers: boolean[]
}>({
open: false,
qIndex: null,
loading: false,
original: null,
improved: null,
applyQuestion: true,
applyAnswers: [],
})
// Distractors state: qIndex → loading
const [distractorsLoading, setDistractorsLoading] = useState<Record<number, boolean>>({})
// Review modal state
const [reviewOpen, setReviewOpen] = useState(false)
const [reviewLoading, setReviewLoading] = useState(false)
const [reviewText, setReviewText] = useState('')
// improve_all state (inside review modal)
const [improveAllLoading, setImproveAllLoading] = useState(false)
const [improveAllData, setImproveAllData] = useState<{
original: { text: string; answers: { text: string; is_correct: boolean }[] }[]
improved: { question: string; answers: string[] }[]
applyQuestions: boolean[]
applyAnswers: boolean[][]
} | null>(null)
const defaultValues: Partial<TestFormValues> = { const defaultValues: Partial<TestFormValues> = {
allow_navigation_back: true, allow_navigation_back: true,
has_timer: false, has_timer: false,
passing_score: 70, passing_score: 70,
questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), questions: Array(7)
.fill(null)
.map(() => ({ ...EMPTY_QUESTION })),
...initialValues, ...initialValues,
} }
// ── AI: Генерация вопросов ──────────────────────────────────────────────
const handleGenerate = async () => {
const title = form.getFieldValue('title') as string
if (!title?.trim()) return
setGenerateLoading(true)
setPreviewQuestions(null)
setPreviewOpen(true)
try {
const data = await llmApi.generate(title.trim(), 7)
setPreviewQuestions(data.questions)
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать вопросы' })
setPreviewOpen(false)
} finally {
setGenerateLoading(false)
}
}
const handleGenerateApply = () => {
if (!previewQuestions) return
form.setFieldValue('questions', previewQuestions)
setPreviewOpen(false)
setPreviewQuestions(null)
notifApi.success({ message: `Добавлено ${previewQuestions.length} вопросов` })
}
const handleGenerateClose = () => {
setPreviewOpen(false)
setPreviewQuestions(null)
}
// ── AI: Улучшить формулировку ──────────────────────────────────────────
const handleImprove = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед улучшением' })
return
}
const answers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
setImproveState({
open: true,
qIndex,
loading: true,
original: { text: questionText, answers },
improved: null,
applyQuestion: true,
applyAnswers: answers.map(() => true),
})
try {
const answerTexts = answers.map((a) => a.text)
const data = await llmApi.improve(questionText, answerTexts)
setImproveState((prev) => ({
...prev,
loading: false,
improved: { question: data.improved_question, answers: data.improved_answers },
applyAnswers: data.improved_answers.map(() => true),
}))
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось улучшить вопрос' })
setImproveState((prev) => ({ ...prev, open: false, loading: false }))
}
}
const handleImproveApply = () => {
const { qIndex, original, improved, applyQuestion, applyAnswers } = improveState
if (qIndex === null || !original || !improved) return
if (applyQuestion) {
form.setFieldValue(['questions', qIndex, 'text'], improved.question)
}
const updatedAnswers = original.answers.map((answer, i) => ({
...answer,
text: applyAnswers[i] && improved.answers[i] ? improved.answers[i] : answer.text,
}))
form.setFieldValue(['questions', qIndex, 'answers'], updatedAnswers)
setImproveState((prev) => ({ ...prev, open: false }))
notifApi.success({ message: 'Изменения применены' })
}
// ── AI: Добавить дистракторы ────────────────────────────────────────────
const handleDistractors = async (qIndex: number) => {
const questionText: string = form.getFieldValue(['questions', qIndex, 'text']) || ''
if (!questionText.trim()) {
notifApi.warning({ message: 'Введите текст вопроса перед генерацией дистракторов' })
return
}
const currentAnswers: { text: string; is_correct: boolean }[] =
form.getFieldValue(['questions', qIndex, 'answers']) || []
const answerTexts = currentAnswers.map((a) => a.text).filter(Boolean)
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: true }))
try {
const data = await llmApi.distractors(questionText, answerTexts)
const newAnswers = [
...currentAnswers,
...data.distractors.map((d) => ({ text: d, is_correct: false })),
]
form.setFieldValue(['questions', qIndex, 'answers'], newAnswers)
notifApi.success({ message: `Добавлено ${data.distractors.length} дистракторов` })
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать дистракторы' })
} finally {
setDistractorsLoading((prev) => ({ ...prev, [qIndex]: false }))
}
}
// ── AI: Проверить тест ─────────────────────────────────────────────────
const handleReview = async () => {
const values = form.getFieldsValue()
if (!values.title) {
notifApi.warning({ message: 'Введите название теста перед проверкой' })
return
}
setReviewLoading(true)
setReviewOpen(true)
setReviewText('')
setImproveAllData(null)
try {
const data = await llmApi.review(values.title, values.questions || [])
setReviewText(data.review)
} catch {
setReviewText('Не удалось получить рекомендации. Проверьте API ключ в настройках.')
} finally {
setReviewLoading(false)
}
}
const handleImproveAll = async () => {
const values = form.getFieldsValue()
const original: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
(values.questions || []).map((q: { text: string; answers: { text: string; is_correct: boolean }[] }) => ({
text: q.text,
answers: q.answers || [],
}))
setImproveAllLoading(true)
try {
const data = await llmApi.improveAll(values.title, values.questions || [])
setImproveAllData({
original,
improved: data.questions,
applyQuestions: data.questions.map(() => true),
applyAnswers: data.questions.map((q) => q.answers.map(() => true)),
})
} catch {
notifApi.error({ message: 'Ошибка AI', description: 'Не удалось сгенерировать улучшения' })
} finally {
setImproveAllLoading(false)
}
}
const handleImproveAllApply = () => {
if (!improveAllData) return
const questions: { text: string; answers: { text: string; is_correct: boolean }[] }[] =
form.getFieldValue('questions') || []
const updated = questions.map((q, qi) => ({
...q,
text:
improveAllData.applyQuestions[qi] && improveAllData.improved[qi]
? improveAllData.improved[qi].question
: q.text,
answers: q.answers.map((a, ai) => ({
...a,
text:
improveAllData.applyAnswers[qi]?.[ai] && improveAllData.improved[qi]?.answers[ai]
? improveAllData.improved[qi].answers[ai]
: a.text,
})),
}))
form.setFieldValue('questions', updated)
setReviewOpen(false)
setImproveAllData(null)
notifApi.success({ message: 'Изменения применены' })
}
return ( return (
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> <div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}>
{contextHolder}
{onBack && ( {onBack && (
<Button <Button icon={<ArrowLeftOutlined />} onClick={onBack} style={{ marginBottom: 16 }}>
icon={<ArrowLeftOutlined />}
onClick={onBack}
style={{ marginBottom: 16 }}
>
{backLabel} {backLabel}
</Button> </Button>
)} )}
@ -129,6 +363,31 @@ export default function TestForm({
</Form.Item> </Form.Item>
</Card> </Card>
{/* ── AI: кнопки ── */}
<Form.Item
noStyle
shouldUpdate={(prev, cur) => prev.title !== cur.title}
>
{({ getFieldValue }) => (
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button
icon={<RobotOutlined />}
onClick={handleGenerate}
loading={generateLoading}
disabled={!getFieldValue('title')?.trim()}
>
Сгенерировать с AI
</Button>
<Button
icon={<StarOutlined />}
onClick={handleReview}
>
Проверить тест
</Button>
</div>
)}
</Form.Item>
{/* ── Вопросы ── */} {/* ── Вопросы ── */}
<Form.List <Form.List
name="questions" name="questions"
@ -149,12 +408,32 @@ export default function TestForm({
key={key} key={key}
title={`Вопрос ${index + 1}`} title={`Вопрос ${index + 1}`}
extra={ extra={
questionFields.length > 7 ? ( <Space>
<Button
size="small"
icon={<RobotOutlined />}
loading={improveState.loading && improveState.qIndex === index}
onClick={() => handleImprove(index)}
title="Улучшить формулировку"
>
Улучшить
</Button>
<Button
size="small"
icon={<PlusOutlined />}
loading={distractorsLoading[index]}
onClick={() => handleDistractors(index)}
title="Добавить дистракторы"
>
Дистракторы
</Button>
{questionFields.length > 7 && (
<MinusCircleOutlined <MinusCircleOutlined
style={{ color: '#ff4d4f', fontSize: 16 }} style={{ color: '#ff4d4f', fontSize: 16 }}
onClick={() => removeQuestion(qName)} onClick={() => removeQuestion(qName)}
/> />
) : null )}
</Space>
} }
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
@ -263,6 +542,327 @@ export default function TestForm({
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
{/* ── Modal: Превью сгенерированных вопросов ── */}
<Modal
title={<><RobotOutlined /> Сгенерированные вопросы</>}
open={previewOpen}
onCancel={handleGenerateClose}
footer={null}
width={640}
>
{generateLoading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI генерирует вопросы...</div>
</div>
)}
{!generateLoading && previewQuestions && (
<>
<Text type="secondary">
Сгенерировано {previewQuestions.length} вопросов. Нажмите «Применить», чтобы
заменить вопросы в форме.
</Text>
<div
style={{
maxHeight: 340,
overflowY: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: 12,
marginTop: 12,
marginBottom: 16,
background: '#fafafa',
}}
>
{previewQuestions.map((q, i) => (
<div key={i} style={{ marginBottom: 12 }}>
<Text strong>
{i + 1}. {q.text}
</Text>
<ul style={{ margin: '4px 0 0 16px', padding: 0 }}>
{q.answers.map((a, ai) => (
<li key={ai} style={{ color: a.is_correct ? '#52c41a' : undefined }}>
{a.text} {a.is_correct && '✓'}
</li>
))}
</ul>
</div>
))}
</div>
<Space>
<Button type="primary" onClick={handleGenerateApply}>
Применить все вопросы
</Button>
<Button onClick={handleGenerate}>Сгенерировать заново</Button>
<Button onClick={handleGenerateClose}>Отмена</Button>
</Space>
</>
)}
</Modal>
{/* ── Modal: Улучшение вопроса ── */}
<Modal
title={
<>
<RobotOutlined /> Улучшение вопроса{' '}
{improveState.qIndex !== null ? improveState.qIndex + 1 : ''}
</>
}
open={improveState.open}
onCancel={() => setImproveState((prev) => ({ ...prev, open: false }))}
width={680}
footer={
improveState.improved ? (
<Space>
<Button type="primary" onClick={handleImproveApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveState((prev) => ({ ...prev, open: false }))}>
Отмена
</Button>
</Space>
) : null
}
>
{improveState.loading && (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI улучшает формулировки...</div>
</div>
)}
{!improveState.loading && improveState.improved && improveState.original && (
<>
{/* Вопрос */}
<div style={{ marginBottom: 16 }}>
<Text strong>Текст вопроса</Text>
<div
style={{
display: 'flex',
gap: 12,
marginTop: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Текущая
</Text>
<div>{improveState.original.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>{improveState.improved.question}</div>
</div>
</div>
<Checkbox
checked={improveState.applyQuestion}
onChange={(e) =>
setImproveState((prev) => ({ ...prev, applyQuestion: e.target.checked }))
}
>
Применить
</Checkbox>
</div>
</div>
{/* Ответы */}
<Text strong>Варианты ответов</Text>
<div style={{ marginTop: 8 }}>
{improveState.original.answers.map((answer, i) => (
<div
key={i}
style={{
display: 'flex',
gap: 12,
marginBottom: 8,
padding: 12,
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div style={{ flex: 1 }}>
<div style={{ marginBottom: 6 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {i + 1}{answer.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div>{answer.text}</div>
</div>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveState.improved.answers[i] ?? answer.text}
</div>
</div>
</div>
<Checkbox
checked={improveState.applyAnswers[i]}
onChange={(e) => {
const next = [...improveState.applyAnswers]
next[i] = e.target.checked
setImproveState((prev) => ({ ...prev, applyAnswers: next }))
}}
>
Применить
</Checkbox>
</div>
))}
</div>
</>
)}
</Modal>
{/* ── Modal: Проверка теста + улучшение всего теста ── */}
<Modal
title={
improveAllData
? <><RobotOutlined /> Предложения по улучшению теста</>
: <><StarOutlined /> Рекомендации AI</>
}
open={reviewOpen}
onCancel={() => { setReviewOpen(false); setImproveAllData(null) }}
width={700}
footer={
improveAllData ? (
<Space>
<Button type="primary" onClick={handleImproveAllApply}>
Применить выбранные
</Button>
<Button onClick={() => setImproveAllData(null)}>
К рекомендациям
</Button>
<Button onClick={() => { setReviewOpen(false); setImproveAllData(null) }}>
Закрыть
</Button>
</Space>
) : (
<Space>
{!reviewLoading && reviewText && (
<Button
type="primary"
icon={<RobotOutlined />}
loading={improveAllLoading}
onClick={handleImproveAll}
>
Предложить вариант
</Button>
)}
<Button onClick={() => setReviewOpen(false)}>Закрыть</Button>
</Space>
)
}
>
{/* Режим 1: рекомендации */}
{!improveAllData && (
reviewLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI анализирует тест...</div>
</div>
) : improveAllLoading ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<RobotOutlined style={{ fontSize: 32, color: '#1677ff' }} spin />
<div style={{ marginTop: 12, color: '#666' }}>AI готовит улучшенный вариант...</div>
</div>
) : (
<Paragraph style={{ whiteSpace: 'pre-wrap' }}>{reviewText}</Paragraph>
)
)}
{/* Режим 2: сравнение старого и нового */}
{improveAllData && (
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
{improveAllData.original.map((origQ, qi) => (
<div key={qi} style={{ marginBottom: 20 }}>
{/* Заголовок вопроса */}
<div
style={{
display: 'flex',
gap: 12,
padding: 12,
background: '#f0f5ff',
borderRadius: 8,
border: '1px solid #d6e4ff',
marginBottom: 6,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вопрос {qi + 1} текущий
</Text>
<div style={{ marginBottom: 4 }}>{origQ.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>
AI предлагает
</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.question ?? origQ.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyQuestions[qi]}
onChange={(e) => {
const next = [...improveAllData.applyQuestions]
next[qi] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyQuestions: next })
}}
>
Применить
</Checkbox>
</div>
{/* Ответы */}
{origQ.answers.map((origA, ai) => (
<div
key={ai}
style={{
display: 'flex',
gap: 12,
padding: '8px 12px',
background: '#fafafa',
borderRadius: 6,
border: '1px solid #f0f0f0',
marginBottom: 4,
marginLeft: 16,
}}
>
<div style={{ flex: 1 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Вариант {ai + 1}{origA.is_correct ? ' (правильный ✓)' : ''}
</Text>
<div style={{ marginBottom: 2 }}>{origA.text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>AI предлагает</Text>
<div style={{ color: '#1677ff' }}>
{improveAllData.improved[qi]?.answers[ai] ?? origA.text}
</div>
</div>
<Checkbox
checked={improveAllData.applyAnswers[qi]?.[ai]}
onChange={(e) => {
const next = improveAllData.applyAnswers.map((row) => [...row])
next[qi][ai] = e.target.checked
setImproveAllData((prev) => prev && { ...prev, applyAnswers: next })
}}
>
Применить
</Checkbox>
</div>
))}
</div>
))}
</div>
)}
</Modal>
</div> </div>
) )
} }

124
frontend/src/pages/Settings/index.tsx

@ -0,0 +1,124 @@
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Alert, Button, Card, Form, Input, Space, Spin, Typography } from 'antd'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import llmApi from '../../api/llm'
import settingsApi from '../../api/settings'
const { Title, Text } = Typography
const API_KEY = 'deepseek_api_key'
export default function Settings() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [checkResult, setCheckResult] = useState<{ ok: boolean; message: string } | null>(null)
const { data: setting, isLoading } = useQuery({
queryKey: ['settings', API_KEY],
queryFn: () => settingsApi.get(API_KEY),
})
const saveMutation = useMutation({
mutationFn: (value: string) => settingsApi.update(API_KEY, value || null),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', API_KEY] })
setCheckResult(null)
},
})
const checkMutation = useMutation({
mutationFn: () => llmApi.check(),
onSuccess: (data) => setCheckResult(data),
})
const handleSave = (values: { api_key: string }) => {
saveMutation.mutate(values.api_key)
}
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 80 }}>
<Spin size="large" />
</div>
)
}
return (
<div style={{ maxWidth: 600, margin: '40px auto', padding: '0 24px' }}>
<Title level={2}>Настройки</Title>
<Card title="AI-помощник (DeepSeek)">
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Введите API ключ DeepSeek для активации AI-функций при создании и редактировании
тестов. Ключ хранится только на сервере.
</Text>
<Form layout="vertical" onFinish={handleSave} initialValues={{ api_key: setting?.value ?? '' }}>
<Form.Item
name="api_key"
label="API ключ DeepSeek"
>
<Input.Password
placeholder="sk-..."
visibilityToggle
style={{ fontFamily: 'monospace' }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Space wrap>
<Button
type="primary"
htmlType="submit"
loading={saveMutation.isPending}
>
Сохранить
</Button>
<Button
onClick={() => checkMutation.mutate()}
loading={checkMutation.isPending}
disabled={!setting?.value}
>
Проверить подключение
</Button>
<Button onClick={() => navigate('/')}>
На главную
</Button>
</Space>
</Form.Item>
</Form>
{saveMutation.isSuccess && (
<Alert
type="success"
message="Ключ сохранён"
showIcon
style={{ marginTop: 16 }}
closable
/>
)}
{checkResult && (
<Alert
type={checkResult.ok ? 'success' : 'error'}
message={checkResult.message}
icon={
checkResult.ok ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)
}
showIcon
style={{ marginTop: 16 }}
closable
onClose={() => setCheckResult(null)}
/>
)}
</Card>
</div>
)
}
Loading…
Cancel
Save