Спринт 4: AI-помощник на базе DeepSeek
- Страница /settings: ввод и проверка API ключа DeepSeek - POST /api/llm/generate — генерация вопросов по названию теста - POST /api/llm/improve — улучшение формулировки вопроса + ответов (модал с галочками) - POST /api/llm/distractors — генерация дистракторов - POST /api/llm/review — рецензия теста + кнопка «Предложить вариант» - POST /api/llm/improve_all — улучшение всего теста с постатейным сравнением - Миграция 004: таблица settings (key-value) - Шапка приложения с навигацией на /settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
+3
-1
@@ -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")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user