diff --git a/DOC/СПРИНТЫ.md b/DOC/СПРИНТЫ.md index 471eb03..68adb5d 100644 --- a/DOC/СПРИНТЫ.md +++ b/DOC/СПРИНТЫ.md @@ -102,34 +102,41 @@ --- -## Спринт 4 — AI-помощник (DeepSeek) +## Спринт 4 — AI-помощник (DeepSeek) ✅ **Результат:** При создании и редактировании теста доступен AI-ассистент на базе DeepSeek. Ключ API настраивается через страницу настроек. +**Статус:** Завершён и протестирован вручную в браузере. ### Страница настроек (`/settings`) -- [ ] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`) -- [ ] Миграция `004` -- [ ] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}` -- [ ] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek) -- [ ] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение» +- [x] Модель БД: `Setting` (key-value, ключ `deepseek_api_key`) +- [x] Миграция `004` +- [x] API: `GET /api/settings/{key}`, `PUT /api/settings/{key}` +- [x] API: `POST /api/llm/check` — проверить подключение (тестовый запрос к DeepSeek) +- [x] Фронт: страница `/settings` — поле для ввода ключа + кнопка «Проверить подключение» ### AI-функции в форме создания/редактирования теста -- [ ] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме -- [ ] API: `POST /api/llm/improve` — улучшить формулировку вопроса -- [ ] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу -- [ ] API: `POST /api/llm/review` — проверить качество всего теста +- [x] API: `POST /api/llm/generate` — сгенерировать вопросы и ответы по теме +- [x] API: `POST /api/llm/improve` — улучшить формулировку вопроса +- [x] API: `POST /api/llm/distractors` — добавить варианты-дистракторы к вопросу +- [x] API: `POST /api/llm/review` — проверить качество всего теста ### Интеграция в UI -- [ ] Кнопка «Сгенерировать с AI» на странице создания теста — вводишь тему, получаешь готовый набор вопросов -- [ ] Кнопка «✨» рядом с каждым вопросом — улучшить формулировку -- [ ] Кнопка «+ Дистракторы» рядом с каждым вопросом — дополнить неправильные варианты -- [ ] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации -- [ ] Ссылка на страницу `/settings` в шапке приложения +- [x] Кнопка «Сгенерировать с AI» над списком вопросов — ввод темы → превью → «Применить все вопросы» +- [x] Кнопка «Улучшить» в шапке каждого вопроса — заменяет формулировку AI-версией +- [x] Кнопка «Дистракторы» в шапке каждого вопроса — добавляет 3 новых неправильных варианта +- [x] Кнопка «Проверить тест» — AI анализирует весь тест и выдаёт рекомендации в модале +- [x] Ссылка «Настройки» в шапке приложения (новый Layout с AppHeader) ### Технические детали - DeepSeek API совместим с форматом OpenAI — используем библиотеку `openai` с `base_url=https://api.deepseek.com` - Модель: `deepseek-chat` - Ключ хранится в таблице `settings`, передаётся из бэкенда — фронт ключ не видит +- `response_format={"type": "json_object"}` для generate, distractors, improve, improve_all — гарантирует структурированный ответ + +### Доработки после тестирования +- [x] «Сгенерировать с AI»: убран вопрос про тему — используется название теста; кнопка задизаблена пока название не заполнено +- [x] «Улучшить»: открывает модал с постатейным сравнением (вопрос + каждый ответ) и галочками вместо прямой замены +- [x] «Проверить тест»: добавлена кнопка «Предложить вариант» — вызывает `POST /api/llm/improve_all`, показывает сравнение всего теста с галочками --- diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_011.md b/DOC/ШАГИ/ШАГ_2026-03-21_011.md new file mode 100644 index 0000000..e5174dd --- /dev/null +++ b/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 diff --git a/DOC/ШАГИ/ШАГ_2026-03-21_012.md b/DOC/ШАГИ/ШАГ_2026-03-21_012.md new file mode 100644 index 0000000..dae0bd3 --- /dev/null +++ b/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 diff --git a/README.md b/README.md index 531752a..c263261 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ | **1** | Инфраструктура (Docker, FastAPI, React, PostgreSQL) + создание тестов | ✅ | | **2** | Прохождение теста + результаты и разбор ошибок | ✅ | | **3** | Редактирование тестов + версионность | ✅ | -| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ⬜ | +| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ | | **5** | Трекер результатов | ⬜ | | **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ | | **7** | Уведомления в MAX | ⬜ | @@ -57,7 +57,7 @@ | Файл | Содержание | |------|-----------| -| `DOC/ТЗ.md` | Техническое задание (v1.1) | +| `DOC/ТЗ.md` | Техническое задание (v1.2) | | `DOC/СТЕК.md` | Технологический стек с обоснованием | | `DOC/СПРИНТЫ.md` | Детальный план спринтов с задачами | | `DOC/ШАГИ/` | История разработки шаг за шагом | diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 8d4701a..77009d1 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -15,7 +15,7 @@ if config.config_file_name is not None: # Берём DATABASE_URL из настроек приложения from app.config import settings 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) diff --git a/backend/alembic/versions/004_settings.py b/backend/alembic/versions/004_settings.py new file mode 100644 index 0000000..08afef7 --- /dev/null +++ b/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") diff --git a/backend/app/api/llm.py b/backend/app/api/llm.py new file mode 100644 index 0000000..dd910de --- /dev/null +++ b/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)}") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py new file mode 100644 index 0000000..99de0cf --- /dev/null +++ b/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 diff --git a/backend/app/main.py b/backend/app/main.py index b15efa2..34b0661 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api import attempts, tests +from app.api import attempts, llm, settings, tests app = FastAPI( title="QA Test App API", @@ -20,6 +20,8 @@ app.add_middleware( app.include_router(tests.router) app.include_router(attempts.router) +app.include_router(settings.router) +app.include_router(llm.router) @app.get("/api/health") diff --git a/backend/app/models/setting.py b/backend/app/models/setting.py new file mode 100644 index 0000000..5940081 --- /dev/null +++ b/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() + ) diff --git a/backend/app/schemas/setting.py b/backend/app/schemas/setting.py new file mode 100644 index 0000000..a0ed20c --- /dev/null +++ b/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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/llm.py b/backend/app/services/llm.py new file mode 100644 index 0000000..89e2df9 --- /dev/null +++ b/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"] diff --git a/backend/requirements.txt b/backend/requirements.txt index c68a4aa..283a9e4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,4 @@ asyncpg==0.29.0 alembic==1.13.3 pydantic==2.9.2 pydantic-settings==2.5.2 +openai==1.57.0 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a73642f..c8642c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,30 +1,71 @@ +import { SettingOutlined } from '@ant-design/icons' 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 { BrowserRouter, Route, Routes } from 'react-router-dom' +import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom' import AttemptResult from './pages/AttemptResult' +import Settings from './pages/Settings' import TestCreate from './pages/TestCreate' import TestDetail from './pages/TestDetail' import TestEdit from './pages/TestEdit' import TestList from './pages/TestList' import TestTake from './pages/TestTake' +const { Header, Content } = Layout + const queryClient = new QueryClient() +function AppHeader() { + const navigate = useNavigate() + return ( +
+ navigate('/')} + > + QA Test App + + +
+ ) +} + export default function App() { return ( - - } /> - } /> - } /> - } /> - } /> - } /> - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + diff --git a/frontend/src/api/llm.ts b/frontend/src/api/llm.ts new file mode 100644 index 0000000..a83565f --- /dev/null +++ b/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 diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts new file mode 100644 index 0000000..b81f6ac --- /dev/null +++ b/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(`/api/settings/${key}`).then((r) => r.data), + update: (key: string, value: string | null) => + axios.put(`/api/settings/${key}`, { value }).then((r) => r.data), +} + +export default settingsApi diff --git a/frontend/src/components/TestForm/index.tsx b/frontend/src/components/TestForm/index.tsx index ceee10e..3493229 100644 --- a/frontend/src/components/TestForm/index.tsx +++ b/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 { Button, Card, @@ -6,13 +12,17 @@ import { Form, Input, InputNumber, + Modal, Space, Switch, Typography, + notification, } 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_QUESTION = { text: '', answers: [EMPTY_ANSWER, EMPTY_ANSWER, EMPTY_ANSWER] } @@ -49,23 +59,247 @@ export default function TestForm({ backLabel = 'Назад', }: TestFormProps) { const [form] = Form.useForm() + const [notifApi, contextHolder] = notification.useNotification() + + // Generate state + const [generateLoading, setGenerateLoading] = useState(false) + const [previewOpen, setPreviewOpen] = useState(false) + const [previewQuestions, setPreviewQuestions] = useState(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>({}) + + // 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 = { allow_navigation_back: true, has_timer: false, passing_score: 70, - questions: Array(7).fill(null).map(() => ({ ...EMPTY_QUESTION })), + questions: Array(7) + .fill(null) + .map(() => ({ ...EMPTY_QUESTION })), ...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 (
+ {contextHolder} + {onBack && ( - )} @@ -129,6 +363,31 @@ export default function TestForm({ + {/* ── AI: кнопки ── */} + prev.title !== cur.title} + > + {({ getFieldValue }) => ( +
+ + +
+ )} +
+ {/* ── Вопросы ── */} 7 ? ( - removeQuestion(qName)} - /> - ) : null + + + + {questionFields.length > 7 && ( + removeQuestion(qName)} + /> + )} + } style={{ marginBottom: 16 }} > @@ -263,6 +542,327 @@ export default function TestForm({ + + {/* ── Modal: Превью сгенерированных вопросов ── */} + Сгенерированные вопросы} + open={previewOpen} + onCancel={handleGenerateClose} + footer={null} + width={640} + > + {generateLoading && ( +
+ +
AI генерирует вопросы...
+
+ )} + + {!generateLoading && previewQuestions && ( + <> + + Сгенерировано {previewQuestions.length} вопросов. Нажмите «Применить», чтобы + заменить вопросы в форме. + +
+ {previewQuestions.map((q, i) => ( +
+ + {i + 1}. {q.text} + +
    + {q.answers.map((a, ai) => ( +
  • + {a.text} {a.is_correct && '✓'} +
  • + ))} +
+
+ ))} +
+ + + + + + + )} +
+ + {/* ── Modal: Улучшение вопроса ── */} + + Улучшение вопроса{' '} + {improveState.qIndex !== null ? improveState.qIndex + 1 : ''} + + } + open={improveState.open} + onCancel={() => setImproveState((prev) => ({ ...prev, open: false }))} + width={680} + footer={ + improveState.improved ? ( + + + + + ) : null + } + > + {improveState.loading && ( +
+ +
AI улучшает формулировки...
+
+ )} + + {!improveState.loading && improveState.improved && improveState.original && ( + <> + {/* Вопрос */} +
+ Текст вопроса +
+
+
+ + Текущая + +
{improveState.original.text}
+
+
+ + AI предлагает + +
{improveState.improved.question}
+
+
+ + setImproveState((prev) => ({ ...prev, applyQuestion: e.target.checked })) + } + > + Применить + +
+
+ + {/* Ответы */} + Варианты ответов +
+ {improveState.original.answers.map((answer, i) => ( +
+
+
+ + Вариант {i + 1}{answer.is_correct ? ' (правильный ✓)' : ''} + +
{answer.text}
+
+
+ + AI предлагает + +
+ {improveState.improved.answers[i] ?? answer.text} +
+
+
+ { + const next = [...improveState.applyAnswers] + next[i] = e.target.checked + setImproveState((prev) => ({ ...prev, applyAnswers: next })) + }} + > + Применить + +
+ ))} +
+ + )} +
+ + {/* ── Modal: Проверка теста + улучшение всего теста ── */} + Предложения по улучшению теста + : <> Рекомендации AI + } + open={reviewOpen} + onCancel={() => { setReviewOpen(false); setImproveAllData(null) }} + width={700} + footer={ + improveAllData ? ( + + + + + + ) : ( + + {!reviewLoading && reviewText && ( + + )} + + + ) + } + > + {/* Режим 1: рекомендации */} + {!improveAllData && ( + reviewLoading ? ( +
+ +
AI анализирует тест...
+
+ ) : improveAllLoading ? ( +
+ +
AI готовит улучшенный вариант...
+
+ ) : ( + {reviewText} + ) + )} + + {/* Режим 2: сравнение старого и нового */} + {improveAllData && ( +
+ {improveAllData.original.map((origQ, qi) => ( +
+ {/* Заголовок вопроса */} +
+
+ + Вопрос {qi + 1} — текущий + +
{origQ.text}
+ + AI предлагает + +
+ {improveAllData.improved[qi]?.question ?? origQ.text} +
+
+ { + const next = [...improveAllData.applyQuestions] + next[qi] = e.target.checked + setImproveAllData((prev) => prev && { ...prev, applyQuestions: next }) + }} + > + Применить + +
+ + {/* Ответы */} + {origQ.answers.map((origA, ai) => ( +
+
+ + Вариант {ai + 1}{origA.is_correct ? ' (правильный ✓)' : ''} + +
{origA.text}
+ AI предлагает +
+ {improveAllData.improved[qi]?.answers[ai] ?? origA.text} +
+
+ { + const next = improveAllData.applyAnswers.map((row) => [...row]) + next[qi][ai] = e.target.checked + setImproveAllData((prev) => prev && { ...prev, applyAnswers: next }) + }} + > + Применить + +
+ ))} +
+ ))} +
+ )} +
) } diff --git a/frontend/src/pages/Settings/index.tsx b/frontend/src/pages/Settings/index.tsx new file mode 100644 index 0000000..e3f5d86 --- /dev/null +++ b/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 ( +
+ +
+ ) + } + + return ( +
+ Настройки + + + + Введите API ключ DeepSeek для активации AI-функций при создании и редактировании + тестов. Ключ хранится только на сервере. + + +
+ + + + + + + + + + + +
+ + {saveMutation.isSuccess && ( + + )} + + {checkResult && ( + + ) : ( + + ) + } + showIcon + style={{ marginTop: 16 }} + closable + onClose={() => setCheckResult(null)} + /> + )} +
+
+ ) +}