Browse Source
- Страница /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
19 changed files with 1485 additions and 43 deletions
@ -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 |
||||||
@ -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 |
||||||
@ -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") |
||||||
@ -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)}") |
||||||
@ -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 |
||||||
@ -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() |
||||||
|
) |
||||||
@ -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,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"] |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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…
Reference in new issue