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