Merged in release/async (pull request #40)
Release/async Approved-by: Tiago Ribeiro
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
from dependency_injector.wiring import inject, Provide
|
||||
from fastapi import APIRouter, Depends, Path, Request
|
||||
from fastapi import APIRouter, Depends, Path, Request, BackgroundTasks
|
||||
|
||||
from app.controllers.abc import IGradeController
|
||||
from app.dtos.writing import WritingGradeTaskDTO
|
||||
from app.dtos.speaking import GradeSpeakingAnswersDTO, GradeSpeakingDTO
|
||||
from app.middlewares import Authorized, IsAuthenticatedViaBearerToken
|
||||
|
||||
controller = "grade_controller"
|
||||
@@ -17,35 +16,51 @@ grade_router = APIRouter()
|
||||
@inject
|
||||
async def grade_writing_task(
|
||||
data: WritingGradeTaskDTO,
|
||||
background_tasks: BackgroundTasks,
|
||||
task: int = Path(..., ge=1, le=2),
|
||||
grade_controller: IGradeController = Depends(Provide[controller])
|
||||
):
|
||||
return await grade_controller.grade_writing_task(task, data)
|
||||
return await grade_controller.grade_writing_task(task, data, background_tasks)
|
||||
|
||||
|
||||
@grade_router.post(
|
||||
'/speaking/2',
|
||||
dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))]
|
||||
)
|
||||
@inject
|
||||
async def grade_speaking_task_2(
|
||||
data: GradeSpeakingDTO,
|
||||
grade_controller: IGradeController = Depends(Provide[controller])
|
||||
):
|
||||
return await grade_controller.grade_speaking_task(2, [data.dict()])
|
||||
|
||||
|
||||
@grade_router.post(
|
||||
'/speaking/{task}',
|
||||
dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))]
|
||||
)
|
||||
@inject
|
||||
async def grade_speaking_task_1_and_3(
|
||||
data: GradeSpeakingAnswersDTO,
|
||||
async def grade_speaking_task(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
task: int = Path(..., ge=1, le=3),
|
||||
grade_controller: IGradeController = Depends(Provide[controller])
|
||||
):
|
||||
return await grade_controller.grade_speaking_task(task, data.answers)
|
||||
form = await request.form()
|
||||
return await grade_controller.grade_speaking_task(task, form, background_tasks)
|
||||
|
||||
|
||||
@grade_router.get(
|
||||
'/pending/{sessionId}',
|
||||
dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))]
|
||||
)
|
||||
@inject
|
||||
async def get_pending_evaluations(
|
||||
session_id: str,
|
||||
grade_controller: IGradeController = Depends(Provide[controller])
|
||||
):
|
||||
return await grade_controller.get_evaluations(session_id, "pending")
|
||||
|
||||
|
||||
@grade_router.get(
|
||||
'/completed/{sessionId}',
|
||||
dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))]
|
||||
)
|
||||
@inject
|
||||
async def get_completed_evaluations(
|
||||
session_id: str,
|
||||
grade_controller: IGradeController = Depends(Provide[controller])
|
||||
):
|
||||
return await grade_controller.get_evaluations(session_id, "completed")
|
||||
|
||||
|
||||
@grade_router.post(
|
||||
|
||||
@@ -18,7 +18,6 @@ async def generate_exercises(
|
||||
dto: LevelExercisesDTO,
|
||||
level_controller: ILevelController = Depends(Provide[controller])
|
||||
):
|
||||
print(dto.dict())
|
||||
return await level_controller.generate_exercises(dto)
|
||||
|
||||
@level_router.get(
|
||||
|
||||
@@ -3,9 +3,8 @@ from typing import Optional
|
||||
|
||||
from dependency_injector.wiring import inject, Provide
|
||||
from fastapi import APIRouter, Path, Query, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.dtos.video import Task, TaskStatus
|
||||
from app.dtos.speaking import Video
|
||||
from app.middlewares import Authorized, IsAuthenticatedViaBearerToken
|
||||
from app.configs.constants import EducationalContent
|
||||
from app.controllers.abc import ISpeakingController
|
||||
@@ -13,11 +12,6 @@ from app.controllers.abc import ISpeakingController
|
||||
controller = "speaking_controller"
|
||||
speaking_router = APIRouter()
|
||||
|
||||
|
||||
class Video(BaseModel):
|
||||
text: str
|
||||
avatar: str
|
||||
|
||||
@speaking_router.post(
|
||||
'/media',
|
||||
dependencies=[Depends(Authorized([IsAuthenticatedViaBearerToken]))]
|
||||
|
||||
@@ -10,19 +10,21 @@ from dotenv import load_dotenv
|
||||
from sentence_transformers import SentenceTransformer
|
||||
|
||||
from app.repositories.impl import *
|
||||
from app.repositories.impl.document_stores.mongo import MongoDB
|
||||
from app.services.impl import *
|
||||
from app.controllers.impl import *
|
||||
from app.services.impl.exam.evaluation import EvaluationService
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class DependencyInjector:
|
||||
|
||||
def __init__(self, polly_client: any, http_client: HTTPClient, whisper_model: any):
|
||||
def __init__(self, polly_client: any, http_client: HTTPClient, stt: OpenAIWhisper):
|
||||
self._container = containers.DynamicContainer()
|
||||
self._polly_client = polly_client
|
||||
self._http_client = http_client
|
||||
self._whisper_model = whisper_model
|
||||
self._stt = stt
|
||||
|
||||
def inject(self):
|
||||
self._setup_clients()
|
||||
@@ -33,22 +35,25 @@ class DependencyInjector:
|
||||
self._container.wire(
|
||||
packages=["app"]
|
||||
)
|
||||
return self
|
||||
|
||||
def _setup_clients(self):
|
||||
self._container.openai_client = providers.Singleton(AsyncOpenAI)
|
||||
self._container.polly_client = providers.Object(self._polly_client)
|
||||
self._container.http_client = providers.Object(self._http_client)
|
||||
self._container.whisper_model = providers.Object(self._whisper_model)
|
||||
self._container.stt = providers.Object(self._stt)
|
||||
|
||||
def _setup_third_parties(self):
|
||||
self._container.llm = providers.Factory(OpenAI, client=self._container.openai_client)
|
||||
self._container.stt = providers.Factory(OpenAIWhisper, model=self._container.whisper_model)
|
||||
self._container.tts = providers.Factory(AWSPolly, client=self._container.polly_client)
|
||||
|
||||
"""
|
||||
with open('app/services/impl/third_parties/elai/conf.json', 'r') as file:
|
||||
elai_conf = json.load(file)
|
||||
|
||||
with open('app/services/impl/third_parties/elai/avatars.json', 'r') as file:
|
||||
elai_avatars = json.load(file)
|
||||
"""
|
||||
|
||||
with open('app/services/impl/third_parties/heygen/avatars.json', 'r') as file:
|
||||
heygen_avatars = json.load(file)
|
||||
@@ -64,8 +69,8 @@ class DependencyInjector:
|
||||
cred = credentials.Certificate(os.getenv("GOOGLE_APPLICATION_CREDENTIALS"))
|
||||
firebase_token = cred.get_access_token().access_token
|
||||
|
||||
self._container.document_store = providers.Object(
|
||||
AsyncIOMotorClient(os.getenv("MONGODB_URI"))[os.getenv("MONGODB_DB")]
|
||||
self._container.document_store = providers.Factory(
|
||||
MongoDB, mongo_db=AsyncIOMotorClient(os.getenv("MONGODB_URI"))[os.getenv("MONGODB_DB")]
|
||||
)
|
||||
|
||||
self._container.firebase_instance = providers.Factory(
|
||||
@@ -123,11 +128,17 @@ class DependencyInjector:
|
||||
UserService, document_store=self._container.document_store
|
||||
)
|
||||
|
||||
self._container.evaluation_service = providers.Factory(
|
||||
EvaluationService, db=self._container.document_store,
|
||||
writing_service=self._container.writing_service,
|
||||
speaking_service=self._container.speaking_service
|
||||
)
|
||||
|
||||
def _setup_controllers(self):
|
||||
|
||||
self._container.grade_controller = providers.Factory(
|
||||
GradeController, grade_service=self._container.grade_service,
|
||||
speaking_service=self._container.speaking_service,
|
||||
writing_service=self._container.writing_service
|
||||
evaluation_service=self._container.evaluation_service
|
||||
)
|
||||
|
||||
self._container.user_controller = providers.Factory(
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Union
|
||||
from fastapi import BackgroundTasks
|
||||
from fastapi.datastructures import FormData
|
||||
|
||||
|
||||
class IGradeController(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def grade_writing_task(self, task: int, data):
|
||||
async def grade_writing_task(
|
||||
self,
|
||||
task: int, dto: any,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict:
|
||||
async def grade_speaking_task(
|
||||
self, task: int, form: FormData, background_tasks: BackgroundTasks
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_evaluations(self, session_id: str, status: str):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -1,34 +1,97 @@
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import BackgroundTasks, Response, HTTPException
|
||||
from fastapi.datastructures import FormData
|
||||
|
||||
from app.configs.constants import FilePaths
|
||||
from app.controllers.abc import IGradeController
|
||||
from app.dtos.evaluation import EvaluationType
|
||||
from app.dtos.speaking import GradeSpeakingItem
|
||||
from app.dtos.writing import WritingGradeTaskDTO
|
||||
from app.helpers import FileHelper
|
||||
from app.services.abc import ISpeakingService, IWritingService, IGradeService
|
||||
from app.utils import handle_exception
|
||||
|
||||
from app.services.abc import IGradeService, IEvaluationService
|
||||
|
||||
class GradeController(IGradeController):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
grade_service: IGradeService,
|
||||
speaking_service: ISpeakingService,
|
||||
writing_service: IWritingService
|
||||
evaluation_service: IEvaluationService,
|
||||
):
|
||||
self._service = grade_service
|
||||
self._speaking_service = speaking_service
|
||||
self._writing_service = writing_service
|
||||
self._evaluation_service = evaluation_service
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def grade_writing_task(self, task: int, data: WritingGradeTaskDTO):
|
||||
return await self._writing_service.grade_writing_task(task, data.question, data.answer)
|
||||
async def grade_writing_task(
|
||||
self,
|
||||
task: int, dto: WritingGradeTaskDTO, background_tasks: BackgroundTasks
|
||||
):
|
||||
await self._evaluation_service.create_evaluation(
|
||||
dto.userId, dto.sessionId, dto.exerciseId, EvaluationType.WRITING, task
|
||||
)
|
||||
|
||||
@handle_exception(400)
|
||||
async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict:
|
||||
FileHelper.delete_files_older_than_one_day(FilePaths.AUDIO_FILES_PATH)
|
||||
return await self._speaking_service.grade_speaking_task(task, answers)
|
||||
await self._evaluation_service.begin_evaluation(
|
||||
dto.userId, dto.sessionId, task, dto.exerciseId, EvaluationType.WRITING, dto, background_tasks
|
||||
)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
async def grade_speaking_task(self, task: int, form: FormData, background_tasks: BackgroundTasks):
|
||||
answers: Dict[int, Dict] = {}
|
||||
user_id = form.get("userId")
|
||||
session_id = form.get("sessionId")
|
||||
exercise_id = form.get("exerciseId")
|
||||
|
||||
if not session_id or not exercise_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Fields sessionId and exerciseId are required!"
|
||||
)
|
||||
|
||||
for key, value in form.items():
|
||||
if '_' not in key:
|
||||
continue
|
||||
|
||||
field_name, index = key.rsplit('_', 1)
|
||||
index = int(index)
|
||||
|
||||
if index not in answers:
|
||||
answers[index] = {}
|
||||
|
||||
if field_name == 'question':
|
||||
answers[index]['question'] = value
|
||||
elif field_name == 'audio':
|
||||
answers[index]['answer'] = value
|
||||
|
||||
for i, answer in answers.items():
|
||||
if 'question' not in answer or 'answer' not in answer:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Incomplete data for answer {i}. Both question and audio required."
|
||||
)
|
||||
|
||||
items = [
|
||||
GradeSpeakingItem(
|
||||
question=answers[i]['question'],
|
||||
answer=answers[i]['answer']
|
||||
)
|
||||
for i in sorted(answers.keys())
|
||||
]
|
||||
|
||||
ex_type = EvaluationType.SPEAKING if task == 2 else EvaluationType.SPEAKING_INTERACTIVE
|
||||
|
||||
await self._evaluation_service.create_evaluation(
|
||||
user_id, session_id, exercise_id, ex_type, task
|
||||
)
|
||||
|
||||
await self._evaluation_service.begin_evaluation(
|
||||
user_id, session_id, task, exercise_id, ex_type, items, background_tasks
|
||||
)
|
||||
|
||||
return Response(status_code=200)
|
||||
|
||||
async def get_evaluations(self, session_id: str, status: str):
|
||||
return await self._evaluation_service.get_evaluations(session_id, status)
|
||||
|
||||
async def grade_short_answers(self, data: Dict):
|
||||
return await self._service.grade_short_answers(data)
|
||||
|
||||
18
app/dtos/evaluation.py
Normal file
18
app/dtos/evaluation.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class EvaluationType(str, Enum):
|
||||
WRITING = "writing"
|
||||
SPEAKING_INTERACTIVE = "speaking_interactive"
|
||||
SPEAKING = "speaking"
|
||||
|
||||
class EvaluationRecord(BaseModel):
|
||||
id: str
|
||||
session_id: str
|
||||
exercise_id: str
|
||||
type: EvaluationType
|
||||
task: int
|
||||
status: str = "pending"
|
||||
result: Optional[Dict] = None
|
||||
@@ -1,27 +1,13 @@
|
||||
import random
|
||||
from typing import List, Dict, Optional
|
||||
from typing import List, Dict
|
||||
|
||||
from fastapi import UploadFile
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.configs.constants import MinTimers
|
||||
|
||||
class Video(BaseModel):
|
||||
text: str
|
||||
avatar: str
|
||||
|
||||
class SaveSpeakingDTO(BaseModel):
|
||||
exercises: List[Dict]
|
||||
minTimer: int = MinTimers.SPEAKING_MIN_TIMER_DEFAULT
|
||||
|
||||
|
||||
class GradeSpeakingDTO(BaseModel):
|
||||
class GradeSpeakingItem(BaseModel):
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
|
||||
class GradeSpeakingAnswersDTO(BaseModel):
|
||||
answers: List[Dict]
|
||||
|
||||
|
||||
class GenerateVideo1DTO(BaseModel):
|
||||
avatar: str = Optional[str]
|
||||
questions: List[str]
|
||||
first_topic: str
|
||||
second_topic: str
|
||||
answer: UploadFile
|
||||
|
||||
@@ -2,5 +2,8 @@ from pydantic import BaseModel
|
||||
|
||||
|
||||
class WritingGradeTaskDTO(BaseModel):
|
||||
userId: str
|
||||
sessionId: str
|
||||
exerciseId: str
|
||||
question: str
|
||||
answer: str
|
||||
|
||||
@@ -34,8 +34,8 @@ class MongoDB(IDocumentStore):
|
||||
cursor = self._mongo_db[collection].find(query)
|
||||
return [document async for document in cursor]
|
||||
|
||||
async def update(self, collection: str, filter_query: Dict, update: Dict) -> Optional[str]:
|
||||
return (await self._mongo_db[collection].update_one(filter_query, update)).upserted_id
|
||||
async def update(self, collection: str, filter_query: Dict, update: Dict) -> Optional[str]:
|
||||
return (await self._mongo_db[collection].update_one(filter_query, update, upsert=True)).upserted_id
|
||||
|
||||
async def get_doc_by_id(self, collection: str, doc_id: str) -> Optional[Dict]:
|
||||
return await self._mongo_db[collection].find_one({"id": doc_id})
|
||||
|
||||
@@ -55,8 +55,7 @@ class FirebaseStorage(IFileStorage):
|
||||
if response.status_code == 200:
|
||||
self._logger.info(f"File {source_file_name} uploaded to {self._storage_url}/o/{destination_blob_name}.")
|
||||
|
||||
# TODO: Test this
|
||||
#await self.make_public(destination_blob_name)
|
||||
await self.make_public(destination_blob_name)
|
||||
|
||||
file_url = f"{self._storage_url}/o/{destination_blob_name}"
|
||||
return file_url
|
||||
|
||||
@@ -12,7 +12,6 @@ from typing import List
|
||||
from http import HTTPStatus
|
||||
|
||||
import httpx
|
||||
import whisper
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
@@ -27,6 +26,7 @@ from app.api import router
|
||||
from app.configs import DependencyInjector
|
||||
from app.exceptions import CustomException
|
||||
from app.middlewares import AuthenticationMiddleware, AuthBackend
|
||||
from app.services.impl import OpenAIWhisper
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -36,8 +36,6 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
https://fastapi.tiangolo.com/advanced/events/
|
||||
"""
|
||||
# Whisper model
|
||||
whisper_model = whisper.load_model("base")
|
||||
|
||||
# NLTK required datasets download
|
||||
nltk.download('words')
|
||||
@@ -56,11 +54,12 @@ async def lifespan(_app: FastAPI):
|
||||
)
|
||||
|
||||
http_client = httpx.AsyncClient()
|
||||
stt = OpenAIWhisper()
|
||||
|
||||
DependencyInjector(
|
||||
polly_client,
|
||||
http_client,
|
||||
whisper_model
|
||||
stt
|
||||
).inject()
|
||||
|
||||
# Setup logging
|
||||
@@ -72,6 +71,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
yield
|
||||
|
||||
stt.close()
|
||||
await http_client.aclose()
|
||||
await polly_client.close()
|
||||
await context_stack.aclose()
|
||||
|
||||
@@ -2,9 +2,11 @@ from .third_parties import *
|
||||
from .exam import *
|
||||
from .training import *
|
||||
from .user import IUserService
|
||||
from .evaluation import IEvaluationService
|
||||
|
||||
__all__ = [
|
||||
"IUserService"
|
||||
"IUserService",
|
||||
"IEvaluationService"
|
||||
]
|
||||
__all__.extend(third_parties.__all__)
|
||||
__all__.extend(exam.__all__)
|
||||
|
||||
34
app/services/abc/evaluation.py
Normal file
34
app/services/abc/evaluation.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from abc import abstractmethod, ABC
|
||||
from typing import Union, List, Dict
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
from app.dtos.evaluation import EvaluationType
|
||||
|
||||
class IEvaluationService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_evaluation(
|
||||
self,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
exercise_id: str,
|
||||
eval_type: EvaluationType,
|
||||
task: int
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def begin_evaluation(
|
||||
self,
|
||||
user_id: str, session_id: str, task: int,
|
||||
exercise_id: str, exercise_type: str,
|
||||
solution: any,
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_evaluations(self, session_id: str, status: str) -> List[Dict]:
|
||||
pass
|
||||
|
||||
@@ -11,6 +11,6 @@ class ISpeakingService(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict:
|
||||
async def grade_speaking_task(self, task: int, items: any) -> Dict:
|
||||
pass
|
||||
|
||||
|
||||
112
app/services/impl/exam/evaluation.py
Normal file
112
app/services/impl/exam/evaluation.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import logging
|
||||
from typing import Union, Dict, List
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
from app.dtos.evaluation import EvaluationType
|
||||
from app.dtos.speaking import GradeSpeakingItem
|
||||
from app.dtos.writing import WritingGradeTaskDTO
|
||||
from app.repositories.abc import IDocumentStore
|
||||
from app.services.abc import IWritingService, ISpeakingService, IEvaluationService
|
||||
|
||||
|
||||
class EvaluationService(IEvaluationService):
|
||||
|
||||
def __init__(self, db: IDocumentStore, writing_service: IWritingService, speaking_service: ISpeakingService):
|
||||
self._db = db
|
||||
self._writing_service = writing_service
|
||||
self._speaking_service = speaking_service
|
||||
self._logger = logging.getLogger(__name__)
|
||||
|
||||
async def create_evaluation(
|
||||
self,
|
||||
user_id: str,
|
||||
session_id: str,
|
||||
exercise_id: str,
|
||||
eval_type: EvaluationType,
|
||||
task: int
|
||||
):
|
||||
await self._db.save_to_db(
|
||||
"evaluation",
|
||||
{
|
||||
"user": user_id,
|
||||
"session_id": session_id,
|
||||
"exercise_id": exercise_id,
|
||||
"type": eval_type,
|
||||
"task": task,
|
||||
"status": "pending"
|
||||
}
|
||||
)
|
||||
|
||||
async def begin_evaluation(
|
||||
self,
|
||||
user_id: str, session_id: str, task: int,
|
||||
exercise_id: str, exercise_type: str,
|
||||
solution: Union[WritingGradeTaskDTO, List[GradeSpeakingItem]],
|
||||
background_tasks: BackgroundTasks
|
||||
):
|
||||
background_tasks.add_task(
|
||||
self._begin_evaluation,
|
||||
user_id, session_id, task,
|
||||
exercise_id, exercise_type,
|
||||
solution
|
||||
)
|
||||
|
||||
async def _begin_evaluation(
|
||||
self, user_id: str, session_id: str, task: int,
|
||||
exercise_id: str, exercise_type: str,
|
||||
solution: Union[WritingGradeTaskDTO, List[GradeSpeakingItem]]
|
||||
):
|
||||
try:
|
||||
if exercise_type == EvaluationType.WRITING:
|
||||
result = await self._writing_service.grade_writing_task(
|
||||
task,
|
||||
solution.question,
|
||||
solution.answer
|
||||
)
|
||||
else:
|
||||
result = await self._speaking_service.grade_speaking_task(
|
||||
task,
|
||||
solution
|
||||
)
|
||||
|
||||
await self._db.update(
|
||||
"evaluation",
|
||||
{
|
||||
"user": user_id,
|
||||
"exercise_id": exercise_id,
|
||||
"session_id": session_id,
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"status": "completed",
|
||||
"result": result,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error processing evaluation {session_id} - {exercise_id}: {str(e)}")
|
||||
await self._db.update(
|
||||
"evaluation",
|
||||
{
|
||||
"user": user_id,
|
||||
"exercise_id": exercise_id,
|
||||
"session_id": session_id
|
||||
},
|
||||
{
|
||||
"$set": {
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async def get_evaluations(self, session_id: str, status: str) -> List[Dict]:
|
||||
return await self._db.find(
|
||||
"evaluation",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"status": status
|
||||
}
|
||||
)
|
||||
@@ -1,8 +1,10 @@
|
||||
from uuid import uuid4
|
||||
|
||||
import aiofiles
|
||||
import os
|
||||
from logging import getLogger
|
||||
|
||||
from typing import Dict, Any, Coroutine, Optional
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
import pdfplumber
|
||||
from fastapi import UploadFile
|
||||
@@ -21,20 +23,19 @@ class UploadLevelModule:
|
||||
self._logger = getLogger(__name__)
|
||||
self._llm = openai
|
||||
|
||||
async def generate_level_from_file(self, file: UploadFile, solutions: Optional[UploadFile]) -> Dict[str, Any] | None:
|
||||
ext, path_id = await FileHelper.save_upload(file)
|
||||
FileHelper.convert_file_to_pdf(
|
||||
f'./tmp/{path_id}/upload.{ext}', f'./tmp/{path_id}/exercises.pdf'
|
||||
)
|
||||
file_has_images = self._check_pdf_for_images(f'./tmp/{path_id}/exercises.pdf')
|
||||
async def generate_level_from_file(self, exercises: UploadFile, solutions: Optional[UploadFile]) -> Dict[str, Any] | None:
|
||||
path_id = str(uuid4())
|
||||
ext, _ = await FileHelper.save_upload(exercises, "exercises", path_id)
|
||||
FileHelper.convert_file_to_html(f'./tmp/{path_id}/exercises.{ext}', f'./tmp/{path_id}/exercises.html')
|
||||
|
||||
if not file_has_images:
|
||||
FileHelper.convert_file_to_html(f'./tmp/{path_id}/upload.{ext}', f'./tmp/{path_id}/exercises.html')
|
||||
if solutions:
|
||||
ext, _ = await FileHelper.save_upload(solutions, "solutions", path_id)
|
||||
FileHelper.convert_file_to_html(f'./tmp/{path_id}/solutions.{ext}', f'./tmp/{path_id}/solutions.html')
|
||||
|
||||
completion: Coroutine[Any, Any, Exam] = (
|
||||
self._png_completion(path_id) if file_has_images else self._html_completion(path_id)
|
||||
)
|
||||
response = await completion
|
||||
#completion: Coroutine[Any, Any, Exam] = (
|
||||
# self._png_completion(path_id) if file_has_images else self._html_completion(path_id)
|
||||
#)
|
||||
response = await self._html_completion(path_id)
|
||||
|
||||
FileHelper.remove_directory(f'./tmp/{path_id}')
|
||||
|
||||
@@ -42,6 +43,7 @@ class UploadLevelModule:
|
||||
return self.fix_ids(response.model_dump(exclude_none=True))
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
@suppress_loggers()
|
||||
def _check_pdf_for_images(pdf_path: str) -> bool:
|
||||
|
||||
@@ -98,7 +98,7 @@ class ImportReadingModule:
|
||||
]
|
||||
}
|
||||
],
|
||||
"text": "<numbered questions with format: <question text>{{<question number>}}\\n>",
|
||||
"text": "<numbered questions with format in square brackets: [<question text>{{<question number>}}\\\\n] notice how there is a double backslash before the n -> I want an escaped newline in your output> ",
|
||||
"type": "writeBlanks",
|
||||
"prompt": "<specific instructions for this exercise section>"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from uuid import uuid4
|
||||
|
||||
import aiofiles
|
||||
import re
|
||||
import uuid
|
||||
from typing import Dict, List, Optional
|
||||
@@ -9,6 +12,7 @@ from app.configs.constants import (
|
||||
FieldsAndExercises, GPTModels, TemperatureSettings,
|
||||
FilePaths
|
||||
)
|
||||
from app.dtos.speaking import GradeSpeakingItem
|
||||
from app.helpers import TextHelper
|
||||
from app.repositories.abc import IFileStorage, IDocumentStore
|
||||
from app.services.abc import ISpeakingService, ILLMService, IVideoGeneratorService, ISpeechToTextService
|
||||
@@ -165,105 +169,120 @@ class SpeakingService(ISpeakingService):
|
||||
|
||||
return response
|
||||
|
||||
async def grade_speaking_task(self, task: int, answers: List[Dict]) -> Dict:
|
||||
request_id = uuid.uuid4()
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - Received request to grade speaking task {task}. '
|
||||
f'Use this id to track the logs: {str(request_id)} - Request data: {str(answers)}'
|
||||
)
|
||||
|
||||
text_answers = []
|
||||
perfect_answers = []
|
||||
async def grade_speaking_task(self, task: int, items: List[GradeSpeakingItem]) -> Dict:
|
||||
request_id = str(uuid.uuid4())
|
||||
self._log(task, request_id, f"Received request to grade speaking task {task}.")
|
||||
|
||||
if task != 2:
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {str(request_id)} - Received {str(len(answers))} total answers.'
|
||||
)
|
||||
self._log(task, request_id, f'Received {len(items)} total answers.')
|
||||
|
||||
for item in answers:
|
||||
sound_file_name = FilePaths.AUDIO_FILES_PATH + str(uuid.uuid4())
|
||||
temp_files = []
|
||||
try:
|
||||
# Save all files first
|
||||
temp_files = await asyncio.gather(*[
|
||||
self.save_file(item) for item in items
|
||||
])
|
||||
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Downloading file {item["answer"]}')
|
||||
# Process all transcriptions concurrently (up to 4)
|
||||
self._log(task, request_id, 'Starting batch transcription')
|
||||
text_answers = await asyncio.gather(*[
|
||||
self._stt.speech_to_text(file_path)
|
||||
for file_path in temp_files
|
||||
])
|
||||
|
||||
await self._file_storage.download_firebase_file(item["answer"], sound_file_name)
|
||||
for answer in text_answers:
|
||||
self._log(task, request_id, f'Transcribed answer: {answer}')
|
||||
if not TextHelper.has_x_words(answer, 20):
|
||||
self._log(
|
||||
task, request_id,
|
||||
f'The answer had less words than threshold 20 to be graded. Answer: {answer}'
|
||||
)
|
||||
return self._zero_rating("The audio recorded does not contain enough english words to be graded.")
|
||||
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - '
|
||||
f'Downloaded file {item["answer"]} to {sound_file_name}'
|
||||
)
|
||||
# Get perfect answers
|
||||
self._log(task, request_id, 'Requesting perfect answers')
|
||||
perfect_answers = await asyncio.gather(*[
|
||||
self._get_perfect_answer(task, item.question)
|
||||
for item in items
|
||||
])
|
||||
|
||||
answer_text = await self._stt.speech_to_text(sound_file_name)
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Transcripted answer: {answer_text}')
|
||||
# Format the responses
|
||||
if task in {1, 3}:
|
||||
self._log(task, request_id, 'Formatting answers and questions for prompt.')
|
||||
|
||||
text_answers.append(answer_text)
|
||||
item["answer"] = answer_text
|
||||
os.remove(sound_file_name)
|
||||
formatted_text = ""
|
||||
for i, (item, transcribed_answer) in enumerate(zip(items, text_answers), start=1):
|
||||
formatted_text += f"**Question {i}:**\n{item.question}\n\n"
|
||||
formatted_text += f"**Answer {i}:**\n{transcribed_answer}\n\n"
|
||||
|
||||
# TODO: This will end the grading of all answers if a single one does not have enough words
|
||||
# don't know if this is intended
|
||||
if not TextHelper.has_x_words(answer_text, 20):
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - '
|
||||
f'The answer had less words than threshold 20 to be graded. Answer: {answer_text}'
|
||||
)
|
||||
return self._zero_rating("The audio recorded does not contain enough english words to be graded.")
|
||||
self._log(task, request_id, f'Formatted answers and questions for prompt: {formatted_text}')
|
||||
questions_and_answers = f'\n\n The questions and answers are: \n\n{formatted_text}'
|
||||
else:
|
||||
questions_and_answers = f'\n Question: "{items[0].question}" \n Answer: "{text_answers[0]}"'
|
||||
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - '
|
||||
f'Requesting perfect answer for question: {item["question"]}'
|
||||
)
|
||||
perfect_answers.append(await self._get_perfect_answer(task, item["question"]))
|
||||
self._log(task, request_id, 'Requesting grading of the answer(s).')
|
||||
response = await self._grade_task(task, questions_and_answers)
|
||||
self._log(task, request_id, f'Answer(s) graded: {response}')
|
||||
|
||||
if task in {1, 3}:
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - Formatting answers and questions for prompt.'
|
||||
)
|
||||
if task in {1, 3}:
|
||||
self._log(task, request_id, 'Adding perfect answer(s) to response.')
|
||||
|
||||
formatted_text = ""
|
||||
for i, entry in enumerate(answers, start=1):
|
||||
formatted_text += f"**Question {i}:**\n{entry['question']}\n\n"
|
||||
formatted_text += f"**Answer {i}:**\n{entry['answer']}\n\n"
|
||||
# TODO: check if it is answer["answer"] instead
|
||||
for i, answer in enumerate(perfect_answers, start=1):
|
||||
response['perfect_answer_' + str(i)] = answer
|
||||
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - '
|
||||
f'Formatted answers and questions for prompt: {formatted_text}'
|
||||
)
|
||||
questions_and_answers = f'\n\n The questions and answers are: \n\n{formatted_text}'
|
||||
else:
|
||||
questions_and_answers = f'\n Question: "{answers[0]["question"]}" \n Answer: "{answers[0]["answer"]}"'
|
||||
self._log(task, request_id, 'Getting speaking corrections in parallel')
|
||||
# Get all corrections in parallel
|
||||
fixed_texts = await asyncio.gather(*[
|
||||
self._get_speaking_corrections(answer)
|
||||
for answer in text_answers
|
||||
])
|
||||
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Requesting grading of the answer(s).')
|
||||
response = await self._grade_task(task, questions_and_answers)
|
||||
self._log(task, request_id, 'Adding transcript and fixed texts to response.')
|
||||
for i, (answer, fixed) in enumerate(zip(text_answers, fixed_texts), start=1):
|
||||
response['transcript_' + str(i)] = answer
|
||||
response['fixed_text_' + str(i)] = fixed
|
||||
else:
|
||||
response['transcript'] = text_answers[0]
|
||||
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Answer(s) graded: {response}')
|
||||
self._log(task, request_id, 'Requesting fixed text.')
|
||||
response['fixed_text'] = await self._get_speaking_corrections(text_answers[0])
|
||||
self._log(task, request_id, f'Fixed text: {response["fixed_text"]}')
|
||||
|
||||
if task in {1, 3}:
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - Adding perfect answer(s) to response.')
|
||||
response['perfect_answer'] = perfect_answers[0]["answer"]
|
||||
|
||||
# TODO: check if it is answer["answer"] instead
|
||||
for i, answer in enumerate(perfect_answers, start=1):
|
||||
response['perfect_answer_' + str(i)] = answer
|
||||
solutions = []
|
||||
for file_name in temp_files:
|
||||
solutions.append(await self._file_storage.upload_file_firebase_get_url(f'{FilePaths.FIREBASE_SPEAKING_VIDEO_FILES_PATH}{uuid4()}.wav', file_name))
|
||||
|
||||
self._logger.info(
|
||||
f'POST - speaking_task_{task} - {request_id} - Adding transcript and fixed texts to response.'
|
||||
)
|
||||
response["overall"] = self._fix_speaking_overall(response["overall"], response["task_response"])
|
||||
response["solutions"] = solutions
|
||||
if task in {1,3}:
|
||||
response["answer"] = solutions
|
||||
else:
|
||||
response["fullPath"] = solutions[0]
|
||||
|
||||
for i, answer in enumerate(text_answers, start=1):
|
||||
response['transcript_' + str(i)] = answer
|
||||
response['fixed_text_' + str(i)] = await self._get_speaking_corrections(answer)
|
||||
else:
|
||||
response['transcript'] = answers[0]["answer"]
|
||||
self._log(task, request_id, f'Final response: {response}')
|
||||
return response
|
||||
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Requesting fixed text.')
|
||||
response['fixed_text'] = await self._get_speaking_corrections(answers[0]["answer"])
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Fixed text: {response["fixed_text"]}')
|
||||
finally:
|
||||
for file_path in temp_files:
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
except Exception as e:
|
||||
self._log(task, request_id, f'Error cleaning up temp file {file_path}: {str(e)}')
|
||||
|
||||
response['perfect_answer'] = perfect_answers[0]["answer"]
|
||||
def _log(self, task: int, request_id: str, message: str):
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - {message}')
|
||||
|
||||
response["overall"] = self._fix_speaking_overall(response["overall"], response["task_response"])
|
||||
self._logger.info(f'POST - speaking_task_{task} - {request_id} - Final response: {response}')
|
||||
return response
|
||||
@staticmethod
|
||||
async def save_file(item: GradeSpeakingItem) -> str:
|
||||
sound_file_name = "tmp/" + str(uuid.uuid4())
|
||||
content = await item.answer.read()
|
||||
async with aiofiles.open(sound_file_name, 'wb') as f:
|
||||
await f.write(content)
|
||||
return sound_file_name
|
||||
|
||||
# ==================================================================================================================
|
||||
# grade_speaking_task helpers
|
||||
@@ -336,7 +355,7 @@ class SpeakingService(ISpeakingService):
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
'For pronunciations act as if you heard the answers and they were transcripted '
|
||||
'For pronunciations act as if you heard the answers and they were transcribed '
|
||||
'as you heard them.'
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
from typing import List, Dict
|
||||
|
||||
from app.services.abc import IWritingService, ILLMService, IAIDetectorService
|
||||
@@ -126,7 +127,7 @@ class WritingService(IWritingService):
|
||||
TemperatureSettings.GEN_QUESTION_TEMPERATURE
|
||||
)
|
||||
|
||||
response = await self._llm.prediction(
|
||||
evaluation_promise = self._llm.prediction(
|
||||
llm_model,
|
||||
messages,
|
||||
["comment"],
|
||||
@@ -134,15 +135,27 @@ class WritingService(IWritingService):
|
||||
)
|
||||
|
||||
perfect_answer_minimum = 150 if task == 1 else 250
|
||||
perfect_answer = await self._get_perfect_answer(question, perfect_answer_minimum)
|
||||
perfect_answer_promise = self._get_perfect_answer(question, perfect_answer_minimum)
|
||||
fixed_text_promise = self._get_fixed_text(answer)
|
||||
ai_detection_promise = self._ai_detector.run_detection(answer)
|
||||
|
||||
response["perfect_answer"] = perfect_answer["perfect_answer"]
|
||||
response["overall"] = ExercisesHelper.fix_writing_overall(response["overall"], response["task_response"])
|
||||
response['fixed_text'] = await self._get_fixed_text(answer)
|
||||
prediction_result, perfect_answer_result, fixed_text_result, ai_detection_result = await asyncio.gather(
|
||||
evaluation_promise,
|
||||
perfect_answer_promise,
|
||||
fixed_text_promise,
|
||||
ai_detection_promise
|
||||
)
|
||||
|
||||
ai_detection = await self._ai_detector.run_detection(answer)
|
||||
if ai_detection is not None:
|
||||
response['ai_detection'] = ai_detection
|
||||
response = prediction_result
|
||||
response["perfect_answer"] = perfect_answer_result["perfect_answer"]
|
||||
response["overall"] = ExercisesHelper.fix_writing_overall(
|
||||
response["overall"],
|
||||
response["task_response"]
|
||||
)
|
||||
response['fixed_text'] = fixed_text_result
|
||||
|
||||
if ai_detection_result is not None:
|
||||
response['ai_detection'] = ai_detection_result
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
import os
|
||||
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
import threading
|
||||
import whisper
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Dict
|
||||
from whisper import Whisper
|
||||
|
||||
from app.services.abc import ISpeechToTextService
|
||||
|
||||
"""
|
||||
The whisper model is not thread safe, a thread pool
|
||||
with 4 whisper models will be created so it can
|
||||
process up to 4 transcriptions at a time.
|
||||
|
||||
The base model requires ~1GB so 4 instances is the safe bet:
|
||||
https://github.com/openai/whisper?tab=readme-ov-file#available-models-and-languages
|
||||
"""
|
||||
class OpenAIWhisper(ISpeechToTextService):
|
||||
def __init__(self, model_name: str = "base", num_models: int = 4):
|
||||
self._model_name = model_name
|
||||
self._num_models = num_models
|
||||
self._models: Dict[int, 'Whisper'] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._next_model_id = 0
|
||||
self._is_closed = False
|
||||
|
||||
def __init__(self, model: Whisper):
|
||||
self._model = model
|
||||
for i in range(num_models):
|
||||
self._models[i] = whisper.load_model(self._model_name, in_memory=True)
|
||||
|
||||
async def speech_to_text(self, file_path):
|
||||
if os.path.exists(file_path):
|
||||
result = await run_in_threadpool(
|
||||
self._model.transcribe, file_path, fp16=False, language='English', verbose=False
|
||||
)
|
||||
return result["text"]
|
||||
else:
|
||||
print("File not found:", file_path)
|
||||
raise Exception("File " + file_path + " not found.")
|
||||
self._executor = ThreadPoolExecutor(
|
||||
max_workers=num_models,
|
||||
thread_name_prefix="whisper_worker"
|
||||
)
|
||||
|
||||
def get_model(self) -> 'Whisper':
|
||||
with self._lock:
|
||||
model_id = self._next_model_id
|
||||
self._next_model_id = (self._next_model_id + 1) % self._num_models
|
||||
return self._models[model_id]
|
||||
|
||||
async def speech_to_text(self, file_path: str) -> str:
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File {file_path} not found.")
|
||||
|
||||
def transcribe():
|
||||
model = self.get_model()
|
||||
return model.transcribe(
|
||||
file_path,
|
||||
fp16=False,
|
||||
language='English',
|
||||
verbose=False
|
||||
)["text"]
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(self._executor, transcribe)
|
||||
|
||||
def close(self):
|
||||
with self._lock:
|
||||
if not self._is_closed:
|
||||
self._is_closed = True
|
||||
if self._executor:
|
||||
self._executor.shutdown(wait=True, cancel_futures=True)
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
Reference in New Issue
Block a user