148 lines
6.0 KiB
Python
148 lines
6.0 KiB
Python
import uuid
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.errors import NotFoundError
|
|
from app.core.security import clean_text
|
|
from app.models.exam import ExamTemplate, ExportFormat, ExportJob, ExportStatus, Question
|
|
from app.schemas.exam import (
|
|
ExamTemplateCreate,
|
|
ExamTemplateRead,
|
|
ExportResponse,
|
|
ParsedQuestionsResponse,
|
|
ParseRequest,
|
|
PromptResponse,
|
|
QuestionCreate,
|
|
QuestionRead,
|
|
)
|
|
from app.services.llm import LLMClient
|
|
from app.services.moodle_exporter import MoodleXMLExporter
|
|
from app.services.parser import AIQuestionParser
|
|
from app.services.prompt_builder import PromptBuilder
|
|
|
|
|
|
class ExamService:
|
|
def __init__(
|
|
self,
|
|
db: Session,
|
|
prompt_builder: PromptBuilder | None = None,
|
|
parser: AIQuestionParser | None = None,
|
|
exporter: MoodleXMLExporter | None = None,
|
|
) -> None:
|
|
self.db = db
|
|
self.prompt_builder = prompt_builder or PromptBuilder()
|
|
self.parser = parser or AIQuestionParser()
|
|
self.exporter = exporter or MoodleXMLExporter()
|
|
|
|
def create_template(self, payload: ExamTemplateCreate) -> ExamTemplateRead:
|
|
template = ExamTemplate(
|
|
title=clean_text(payload.title, max_length=200),
|
|
subject=clean_text(payload.subject, max_length=200),
|
|
educational_level=clean_text(payload.educational_level, max_length=120),
|
|
language=clean_text(payload.language, max_length=20),
|
|
settings=payload.settings.model_dump(mode="json"),
|
|
difficulty_profile=payload.difficulty_profile.model_dump(mode="json"),
|
|
)
|
|
self.db.add(template)
|
|
self.db.commit()
|
|
self.db.refresh(template)
|
|
return self._template_read(template)
|
|
|
|
def list_templates(self) -> list[ExamTemplateRead]:
|
|
templates = self.db.scalars(select(ExamTemplate).order_by(ExamTemplate.created_at.desc())).all()
|
|
return [self._template_read(template) for template in templates]
|
|
|
|
def get_template(self, template_id: uuid.UUID) -> ExamTemplateRead:
|
|
return self._template_read(self._get_template_or_404(template_id))
|
|
|
|
def build_prompt(self, template_id: uuid.UUID, topic_prompt: str) -> PromptResponse:
|
|
template = self._get_template_or_404(template_id)
|
|
prompt = self.prompt_builder.build_prompt(template, topic_prompt)
|
|
return PromptResponse(template_id=template.id, prompt=prompt)
|
|
|
|
async def generate_with_llm(
|
|
self,
|
|
template_id: uuid.UUID,
|
|
topic_prompt: str,
|
|
llm_client: LLMClient,
|
|
) -> ParsedQuestionsResponse:
|
|
template = self._get_template_or_404(template_id)
|
|
prompt = self.prompt_builder.build_prompt(template, topic_prompt)
|
|
raw_output = await llm_client.generate(prompt)
|
|
questions = self.parser.parse_json(raw_output)
|
|
return self._persist_questions(template.id, questions)
|
|
|
|
def parse_and_persist(self, payload: ParseRequest) -> ParsedQuestionsResponse:
|
|
self._get_template_or_404(payload.template_id)
|
|
questions = self.parser.parse(payload.raw_output, payload.input_format)
|
|
return self._persist_questions(payload.template_id, questions)
|
|
|
|
def export(self, template_id: uuid.UUID, export_format: ExportFormat) -> ExportResponse:
|
|
template = self._get_template_or_404(template_id)
|
|
questions = list(template.questions)
|
|
if not questions:
|
|
raise NotFoundError("Template does not contain questions to export")
|
|
|
|
if export_format == ExportFormat.XML:
|
|
content = self.exporter.export_xml(questions)
|
|
elif export_format == ExportFormat.TXT:
|
|
content = self.exporter.export_txt(questions)
|
|
else:
|
|
content = self.exporter.export_json(questions)
|
|
|
|
self.db.add(
|
|
ExportJob(
|
|
template_id=template.id,
|
|
status=ExportStatus.COMPLETED,
|
|
format=export_format,
|
|
content=content,
|
|
)
|
|
)
|
|
self.db.commit()
|
|
return ExportResponse(template_id=template.id, format=export_format, content=content)
|
|
|
|
def _persist_questions(self, template_id: uuid.UUID, questions: list[QuestionCreate]) -> ParsedQuestionsResponse:
|
|
persisted: list[Question] = []
|
|
for payload in questions:
|
|
question = Question(
|
|
template_id=template_id,
|
|
question_type=payload.question_type,
|
|
statement=clean_text(payload.statement),
|
|
correct_answers=[clean_text(answer, max_length=1_000) for answer in payload.correct_answers],
|
|
wrong_answers=[clean_text(answer, max_length=1_000) for answer in payload.wrong_answers],
|
|
matching_pairs=[pair.model_dump() for pair in payload.matching_pairs],
|
|
difficulty=payload.difficulty,
|
|
score=payload.score,
|
|
penalty=payload.penalty,
|
|
options=payload.options,
|
|
)
|
|
self.db.add(question)
|
|
persisted.append(question)
|
|
|
|
self.db.commit()
|
|
for question in persisted:
|
|
self.db.refresh(question)
|
|
|
|
return ParsedQuestionsResponse(questions=[QuestionRead.model_validate(question) for question in persisted])
|
|
|
|
def _get_template_or_404(self, template_id: uuid.UUID) -> ExamTemplate:
|
|
template = self.db.get(ExamTemplate, template_id)
|
|
if template is None:
|
|
raise NotFoundError("Exam template not found")
|
|
return template
|
|
|
|
def _template_read(self, template: ExamTemplate) -> ExamTemplateRead:
|
|
return ExamTemplateRead(
|
|
id=template.id,
|
|
title=template.title,
|
|
subject=template.subject,
|
|
educational_level=template.educational_level,
|
|
language=template.language,
|
|
settings=template.settings,
|
|
difficulty_profile=template.difficulty_profile,
|
|
created_at=template.created_at,
|
|
updated_at=template.updated_at,
|
|
question_count=len(template.questions),
|
|
)
|