7bc27da33a
Upload documents for AI context, exam images for Moodle questions, per-template storage limits, embedded images in XML export, and GUIA_API_Y_FLUJO.md with full endpoint documentation.
156 lines
5.1 KiB
Python
156 lines
5.1 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from typing import Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
|
|
from app.models.exam import Difficulty, ExportFormat, QuestionType
|
|
|
|
|
|
class QuestionTypeSettings(BaseModel):
|
|
type: QuestionType
|
|
count: int = Field(ge=1, le=200)
|
|
options_count: int | None = Field(default=None, ge=2, le=8)
|
|
multiple_correct: bool = False
|
|
score: float = Field(default=1.0, ge=0.0, le=100.0)
|
|
penalty: float = Field(default=0.0, ge=0.0, le=100.0)
|
|
|
|
|
|
class ExamSettings(BaseModel):
|
|
question_types: list[QuestionTypeSettings] = Field(min_length=1, max_length=20)
|
|
shuffle_questions: bool = True
|
|
shuffle_answers: bool = True
|
|
include_feedback: bool = True
|
|
|
|
|
|
class DifficultyProfile(BaseModel):
|
|
easy: int = Field(default=0, ge=0, le=500)
|
|
medium: int = Field(default=0, ge=0, le=500)
|
|
hard: int = Field(default=0, ge=0, le=500)
|
|
very_hard: int = Field(default=0, ge=0, le=500)
|
|
|
|
@model_validator(mode="after")
|
|
def require_at_least_one_question(self) -> "DifficultyProfile":
|
|
if self.easy + self.medium + self.hard + self.very_hard <= 0:
|
|
raise ValueError("At least one difficulty bucket must contain questions")
|
|
return self
|
|
|
|
|
|
class ExamTemplateCreate(BaseModel):
|
|
title: str = Field(min_length=3, max_length=200)
|
|
subject: str = Field(min_length=2, max_length=200)
|
|
educational_level: str = Field(min_length=2, max_length=120)
|
|
language: str = Field(default="es", min_length=2, max_length=20)
|
|
settings: ExamSettings
|
|
difficulty_profile: DifficultyProfile
|
|
|
|
|
|
class ExamTemplateRead(ExamTemplateCreate):
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
question_count: int = 0
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class MatchingPair(BaseModel):
|
|
prompt: str = Field(min_length=1, max_length=1_000)
|
|
answer: str = Field(min_length=1, max_length=1_000)
|
|
|
|
|
|
class QuestionCreate(BaseModel):
|
|
question_type: QuestionType
|
|
statement: str = Field(min_length=3, max_length=8_000)
|
|
correct_answers: list[str] = Field(min_length=1, max_length=20)
|
|
wrong_answers: list[str] = Field(default_factory=list, max_length=20)
|
|
matching_pairs: list[MatchingPair] = Field(default_factory=list, max_length=50)
|
|
image_id: uuid.UUID | None = Field(
|
|
default=None,
|
|
description="ID de imagen de la plantilla que debe mostrarse con la pregunta.",
|
|
)
|
|
difficulty: Difficulty = Difficulty.MEDIUM
|
|
score: float = Field(default=1.0, ge=0.0, le=100.0)
|
|
penalty: float = Field(default=0.0, ge=0.0, le=100.0)
|
|
options: dict[str, object] = Field(default_factory=dict)
|
|
|
|
@field_validator("correct_answers", "wrong_answers")
|
|
@classmethod
|
|
def strip_answers(cls, value: list[str]) -> list[str]:
|
|
return [answer.strip() for answer in value if answer.strip()]
|
|
|
|
@model_validator(mode="after")
|
|
def validate_question_payload(self) -> "QuestionCreate":
|
|
if self.question_type == QuestionType.MULTICHOICE and not self.wrong_answers:
|
|
raise ValueError("Multichoice questions require wrong_answers")
|
|
if self.question_type == QuestionType.TRUE_FALSE:
|
|
accepted = {"true", "false", "verdadero", "falso"}
|
|
if self.correct_answers[0].lower() not in accepted:
|
|
raise ValueError("True/false questions require a true or false correct answer")
|
|
if self.question_type == QuestionType.MATCHING and not self.matching_pairs:
|
|
raise ValueError("Matching questions require matching_pairs")
|
|
return self
|
|
|
|
|
|
class QuestionRead(QuestionCreate):
|
|
id: uuid.UUID
|
|
template_id: uuid.UUID
|
|
image_url: str | None = None
|
|
created_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|
|
|
|
|
|
class PromptResponse(BaseModel):
|
|
template_id: uuid.UUID
|
|
prompt: str
|
|
expected_format: Literal["json"] = "json"
|
|
|
|
|
|
class BuildPromptRequest(BaseModel):
|
|
topic_prompt: str = Field(min_length=5, max_length=4_000)
|
|
material_ids: list[uuid.UUID] | None = Field(
|
|
default=None,
|
|
description="IDs de materiales a incluir. Si no se indica, se usan todos los procesados.",
|
|
)
|
|
|
|
|
|
class GenerateExamRequest(BaseModel):
|
|
template_id: uuid.UUID
|
|
topic_prompt: str = Field(min_length=5, max_length=4_000)
|
|
material_ids: list[uuid.UUID] | None = Field(
|
|
default=None,
|
|
description="IDs de materiales a incluir. Si no se indica, se usan todos los procesados.",
|
|
)
|
|
|
|
|
|
class ParseRequest(BaseModel):
|
|
raw_output: str = Field(min_length=5, max_length=200_000)
|
|
input_format: Literal["json", "txt"]
|
|
template_id: uuid.UUID
|
|
|
|
|
|
class ParsedQuestionsResponse(BaseModel):
|
|
questions: list[QuestionRead]
|
|
|
|
|
|
class ExportResponse(BaseModel):
|
|
template_id: uuid.UUID
|
|
format: ExportFormat
|
|
content: str
|
|
|
|
|
|
class ExamHistoryItem(BaseModel):
|
|
id: uuid.UUID
|
|
title: str
|
|
subject: str
|
|
educational_level: str
|
|
language: str
|
|
question_count: int
|
|
export_count: int
|
|
last_export_at: datetime | None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
model_config = ConfigDict(from_attributes=True)
|