Spaces:
Sleeping
Sleeping
Commit ·
52f5a2a
1
Parent(s): 40112ac
Deploy: Initial Backend Release v1.0
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +6 -0
- Dockerfile +30 -0
- app/api/deps.py +87 -0
- app/api/v1/endpoints/auth.py +40 -0
- app/api/v1/endpoints/conversation.py +48 -0
- app/api/v1/endpoints/dashboard.py +82 -0
- app/api/v1/endpoints/exam.py +55 -0
- app/api/v1/endpoints/history.py +68 -0
- app/api/v1/endpoints/interview_flow.py +63 -0
- app/api/v1/endpoints/material.py +407 -0
- app/api/v1/endpoints/mobile_profile.py +17 -0
- app/api/v1/endpoints/phoneme.py +104 -0
- app/api/v1/endpoints/pretest.py +76 -0
- app/api/v1/endpoints/talents.py +130 -0
- app/api/v1/endpoints/transcribe.py +12 -0
- app/core/config.py +76 -0
- app/core/database.py +31 -0
- app/core/exceptions.py +33 -0
- app/main.py +56 -0
- app/models/models.py +123 -0
- app/repositories/base.py +39 -0
- app/repositories/dashboard_repository.py +167 -0
- app/repositories/exam_repository.py +84 -0
- app/repositories/history_repository.py +104 -0
- app/repositories/material_repository.py +278 -0
- app/repositories/score_repository.py +49 -0
- app/repositories/talent_repository.py +97 -0
- app/schemas/auth.py +17 -0
- app/schemas/conversation.py +33 -0
- app/schemas/dashboard.py +67 -0
- app/schemas/exam.py +20 -0
- app/schemas/material.py +57 -0
- app/schemas/phoneme.py +16 -0
- app/schemas/response.py +9 -0
- app/schemas/talent.py +51 -0
- app/seeder.py +63 -0
- app/services/audio_service.py +100 -0
- app/services/auth_service.py +94 -0
- app/services/conversation_service.py +68 -0
- app/services/dashboard_service.py +151 -0
- app/services/exam_service.py +106 -0
- app/services/interview_service.py +73 -0
- app/services/llm_service.py +146 -0
- app/services/material_service.py +235 -0
- app/services/phoneme_service.py +122 -0
- app/services/profile_service.py +69 -0
- app/services/talent_service.py +165 -0
- app/utils/calculation_utils.py +25 -0
- app/utils/phoneme_utils.py +101 -0
- app/utils/template_generator.py +87 -0
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
.venv
|
| 5 |
+
venv
|
| 6 |
+
.DS_Store
|
Dockerfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PORT=7860
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
ffmpeg \
|
| 11 |
+
libsndfile1 \
|
| 12 |
+
git \
|
| 13 |
+
build-essential \
|
| 14 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 15 |
+
|
| 16 |
+
COPY requirements.txt .
|
| 17 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 18 |
+
pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
COPY . .
|
| 21 |
+
|
| 22 |
+
RUN useradd -m -u 1000 user
|
| 23 |
+
RUN chown -R user:user /app
|
| 24 |
+
USER user
|
| 25 |
+
ENV HOME=/home/user \
|
| 26 |
+
PATH=/home/user/.local/bin:$PATH
|
| 27 |
+
|
| 28 |
+
EXPOSE 7860
|
| 29 |
+
|
| 30 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
app/api/deps.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 3 |
+
from jose import jwt, JWTError
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from app.core.database import get_db
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.models.models import Manajemen
|
| 8 |
+
from sqlalchemy import select
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# OAuth2 Scheme
|
| 14 |
+
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login/talent")
|
| 15 |
+
|
| 16 |
+
async def get_current_user(
|
| 17 |
+
token: str = Depends(oauth2_scheme),
|
| 18 |
+
db: AsyncSession = Depends(get_db)
|
| 19 |
+
) -> dict:
|
| 20 |
+
"""
|
| 21 |
+
Validasi Token untuk Talent (Mobile App)
|
| 22 |
+
"""
|
| 23 |
+
credentials_exception = HTTPException(
|
| 24 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 25 |
+
detail="Could not validate credentials",
|
| 26 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 27 |
+
)
|
| 28 |
+
try:
|
| 29 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 30 |
+
user_id: int = payload.get("id")
|
| 31 |
+
role: str = payload.get("role")
|
| 32 |
+
|
| 33 |
+
if user_id is None or role != "talent":
|
| 34 |
+
logger.warning(f"Talent Auth Failed: UserID={user_id}, Role={role}")
|
| 35 |
+
raise credentials_exception
|
| 36 |
+
|
| 37 |
+
return {"idtalent": user_id, "role": role}
|
| 38 |
+
|
| 39 |
+
except JWTError as e:
|
| 40 |
+
logger.error(f"JWT Error (Talent): {str(e)}")
|
| 41 |
+
raise credentials_exception
|
| 42 |
+
|
| 43 |
+
async def get_current_admin_user(
|
| 44 |
+
token: str = Depends(oauth2_scheme),
|
| 45 |
+
db: AsyncSession = Depends(get_db)
|
| 46 |
+
) -> Manajemen:
|
| 47 |
+
"""
|
| 48 |
+
Validasi Token untuk Admin (Web Dashboard)
|
| 49 |
+
"""
|
| 50 |
+
credentials_exception = HTTPException(
|
| 51 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 52 |
+
detail="Admin credentials invalid",
|
| 53 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 54 |
+
)
|
| 55 |
+
try:
|
| 56 |
+
# 1. Decode Token
|
| 57 |
+
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
| 58 |
+
user_id: int = payload.get("id")
|
| 59 |
+
role: str = payload.get("role")
|
| 60 |
+
|
| 61 |
+
# 2. Validate Role
|
| 62 |
+
if user_id is None:
|
| 63 |
+
logger.warning("Admin Auth Failed: No User ID in token")
|
| 64 |
+
raise credentials_exception
|
| 65 |
+
|
| 66 |
+
# Allow both 'manajemen' and 'admin' roles for flexibility
|
| 67 |
+
if role not in ["manajemen", "admin"]:
|
| 68 |
+
logger.warning(f"Admin Auth Failed: Invalid Role '{role}' for user {user_id}")
|
| 69 |
+
raise credentials_exception
|
| 70 |
+
|
| 71 |
+
# 3. Check Database
|
| 72 |
+
query = select(Manajemen).where(Manajemen.idmanajemen == user_id)
|
| 73 |
+
result = await db.execute(query)
|
| 74 |
+
admin = result.scalar_one_or_none()
|
| 75 |
+
|
| 76 |
+
if admin is None:
|
| 77 |
+
logger.warning(f"Admin Auth Failed: User ID {user_id} not found in DB")
|
| 78 |
+
raise credentials_exception
|
| 79 |
+
|
| 80 |
+
return admin
|
| 81 |
+
|
| 82 |
+
except JWTError as e:
|
| 83 |
+
logger.error(f"JWT Error (Admin): {str(e)}")
|
| 84 |
+
raise credentials_exception
|
| 85 |
+
except Exception as e:
|
| 86 |
+
logger.error(f"Auth Unexpected Error: {str(e)}")
|
| 87 |
+
raise credentials_exception
|
app/api/v1/endpoints/auth.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, status
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.repositories.talent_repository import TalentRepository
|
| 5 |
+
from app.services.auth_service import AuthService
|
| 6 |
+
from app.schemas.auth import LoginRequest, TalentCreate
|
| 7 |
+
from app.schemas.response import ResponseBase
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.post("/login", response_model=ResponseBase)
|
| 12 |
+
async def login_admin(
|
| 13 |
+
request: LoginRequest,
|
| 14 |
+
db: AsyncSession = Depends(get_db)
|
| 15 |
+
):
|
| 16 |
+
repo = TalentRepository(db)
|
| 17 |
+
service = AuthService(repo)
|
| 18 |
+
result = await service.authenticate_admin(request, db)
|
| 19 |
+
return ResponseBase(data=result, message="Admin login successful")
|
| 20 |
+
|
| 21 |
+
# Endpoint Talent (Mobile)
|
| 22 |
+
@router.post("/login/talent", response_model=ResponseBase)
|
| 23 |
+
async def login_talent(
|
| 24 |
+
request: LoginRequest,
|
| 25 |
+
db: AsyncSession = Depends(get_db)
|
| 26 |
+
):
|
| 27 |
+
repo = TalentRepository(db)
|
| 28 |
+
service = AuthService(repo)
|
| 29 |
+
result = await service.authenticate_talent(request)
|
| 30 |
+
return ResponseBase(data=result, message="Login successful")
|
| 31 |
+
|
| 32 |
+
@router.post("/register/talent", response_model=ResponseBase, status_code=status.HTTP_201_CREATED)
|
| 33 |
+
async def register_talent(
|
| 34 |
+
request: TalentCreate,
|
| 35 |
+
db: AsyncSession = Depends(get_db)
|
| 36 |
+
):
|
| 37 |
+
repo = TalentRepository(db)
|
| 38 |
+
service = AuthService(repo)
|
| 39 |
+
result = await service.register_talent(request)
|
| 40 |
+
return ResponseBase(message="Talent created successfully", data={"email": result.email})
|
app/api/v1/endpoints/conversation.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.conversation_service import ConversationService
|
| 5 |
+
from app.schemas.conversation import ChatInput, ChatResponse, ConversationStart, TopicListResponse
|
| 6 |
+
from app.schemas.response import ResponseBase
|
| 7 |
+
from app.api.deps import get_current_user
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.get("/topics", response_model=ResponseBase[TopicListResponse])
|
| 12 |
+
async def get_topics(db: AsyncSession = Depends(get_db)):
|
| 13 |
+
service = ConversationService(db)
|
| 14 |
+
topics = await service.get_topics()
|
| 15 |
+
return ResponseBase(data={"topics": topics})
|
| 16 |
+
|
| 17 |
+
@router.get("/start", response_model=ResponseBase[ConversationStart])
|
| 18 |
+
async def start_conversation(
|
| 19 |
+
db: AsyncSession = Depends(get_db)
|
| 20 |
+
):
|
| 21 |
+
service = ConversationService(db)
|
| 22 |
+
result = await service.start_session()
|
| 23 |
+
|
| 24 |
+
return ResponseBase(
|
| 25 |
+
message="Ready",
|
| 26 |
+
data=ConversationStart(
|
| 27 |
+
topic=result["topic"],
|
| 28 |
+
initial_question=result["message"]
|
| 29 |
+
)
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
@router.post("/chat", response_model=ResponseBase[ChatResponse])
|
| 33 |
+
async def chat(
|
| 34 |
+
input_data: ChatInput,
|
| 35 |
+
db: AsyncSession = Depends(get_db),
|
| 36 |
+
current_user: dict = Depends(get_current_user)
|
| 37 |
+
):
|
| 38 |
+
service = ConversationService(db)
|
| 39 |
+
talent_id = current_user["idtalent"]
|
| 40 |
+
|
| 41 |
+
result = await service.process_chat(
|
| 42 |
+
user_input=input_data.user_input,
|
| 43 |
+
duration=input_data.duration,
|
| 44 |
+
talent_id=talent_id,
|
| 45 |
+
topic_id=input_data.topic_id
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
return ResponseBase(data=ChatResponse(**result))
|
app/api/v1/endpoints/dashboard.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, Body
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.dashboard_service import DashboardService
|
| 5 |
+
from app.schemas.dashboard import (
|
| 6 |
+
DashboardResponse, PaginatedListResponse,
|
| 7 |
+
AdminProfile, AdminUpdate, AdminPasswordUpdate
|
| 8 |
+
)
|
| 9 |
+
from app.schemas.response import ResponseBase
|
| 10 |
+
from app.api.deps import get_current_admin_user
|
| 11 |
+
from app.models.models import Manajemen
|
| 12 |
+
from app.utils.time_utils import TimeUtils
|
| 13 |
+
from typing import Optional
|
| 14 |
+
from datetime import datetime
|
| 15 |
+
|
| 16 |
+
router = APIRouter(dependencies=[Depends(get_current_admin_user)])
|
| 17 |
+
|
| 18 |
+
@router.get("/dashboard", response_model=ResponseBase[DashboardResponse])
|
| 19 |
+
async def get_dashboard(
|
| 20 |
+
startDate: Optional[str] = Query(None),
|
| 21 |
+
endDate: Optional[str] = Query(None),
|
| 22 |
+
activityLimit: int = Query(10, ge=1, le=200),
|
| 23 |
+
daysBack: int = Query(30, ge=1),
|
| 24 |
+
db: AsyncSession = Depends(get_db)
|
| 25 |
+
):
|
| 26 |
+
service = DashboardService(db)
|
| 27 |
+
|
| 28 |
+
start_dt = datetime.strptime(startDate, "%Y-%m-%d") if startDate else None
|
| 29 |
+
end_dt = datetime.strptime(endDate, "%Y-%m-%d") if endDate else None
|
| 30 |
+
|
| 31 |
+
data = await service.get_admin_dashboard(
|
| 32 |
+
limit=activityLimit,
|
| 33 |
+
days_back=daysBack,
|
| 34 |
+
start_date=start_dt,
|
| 35 |
+
end_date=end_dt
|
| 36 |
+
)
|
| 37 |
+
return ResponseBase(data=data, message="Dashboard data retrieved")
|
| 38 |
+
|
| 39 |
+
@router.get("/learners/top-active", response_model=ResponseBase[PaginatedListResponse])
|
| 40 |
+
async def get_top_active(
|
| 41 |
+
searchQuery: str = Query(None), page: int = 1, limit: int = 10, db: AsyncSession = Depends(get_db)
|
| 42 |
+
):
|
| 43 |
+
service = DashboardService(db)
|
| 44 |
+
data = await service.get_leaderboard("topActive", page, limit, searchQuery)
|
| 45 |
+
return ResponseBase(data=data)
|
| 46 |
+
|
| 47 |
+
@router.get("/learners/highest-scoring", response_model=ResponseBase[PaginatedListResponse])
|
| 48 |
+
async def get_highest_scoring(
|
| 49 |
+
category: str = Query("phoneme_material_exercise"), searchQuery: str = Query(None), page: int = 1, limit: int = 10, db: AsyncSession = Depends(get_db)
|
| 50 |
+
):
|
| 51 |
+
service = DashboardService(db)
|
| 52 |
+
data = await service.get_leaderboard(category, page, limit, searchQuery)
|
| 53 |
+
return ResponseBase(data=data)
|
| 54 |
+
|
| 55 |
+
@router.get("/profile", response_model=ResponseBase[AdminProfile])
|
| 56 |
+
async def get_profile(
|
| 57 |
+
current_admin: Manajemen = Depends(get_current_admin_user)
|
| 58 |
+
):
|
| 59 |
+
return ResponseBase(data=AdminProfile(
|
| 60 |
+
idUser=f"ADM{current_admin.idmanajemen:03d}",
|
| 61 |
+
name=current_admin.namamanajemen,
|
| 62 |
+
email=current_admin.email,
|
| 63 |
+
role="Manajemen",
|
| 64 |
+
createdAt="2025-01-01 00:00:00",
|
| 65 |
+
lastLogin=TimeUtils.format_to_wib(TimeUtils.get_current_time())
|
| 66 |
+
))
|
| 67 |
+
|
| 68 |
+
@router.put("/profile", response_model=ResponseBase)
|
| 69 |
+
async def update_profile(
|
| 70 |
+
request: AdminUpdate, db: AsyncSession = Depends(get_db), current_admin: Manajemen = Depends(get_current_admin_user)
|
| 71 |
+
):
|
| 72 |
+
service = DashboardService(db)
|
| 73 |
+
await service.update_admin(current_admin.idmanajemen, request.nama, request.email)
|
| 74 |
+
return ResponseBase(message="Profile updated successfully")
|
| 75 |
+
|
| 76 |
+
@router.put("/profile/change-password", response_model=ResponseBase)
|
| 77 |
+
async def change_password(
|
| 78 |
+
request: AdminPasswordUpdate, db: AsyncSession = Depends(get_db), current_admin: Manajemen = Depends(get_current_admin_user)
|
| 79 |
+
):
|
| 80 |
+
service = DashboardService(db)
|
| 81 |
+
await service.change_admin_password(current_admin.idmanajemen, request.new_password)
|
| 82 |
+
return ResponseBase(message="Password updated successfully")
|
app/api/v1/endpoints/exam.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, UploadFile, File, Form, Header, HTTPException
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.exam_service import ExamService
|
| 5 |
+
from app.schemas.response import ResponseBase
|
| 6 |
+
from app.schemas.exam import ExamStartResponse, ExamResultResponse
|
| 7 |
+
from app.api.deps import get_current_user
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
@router.get("/start/{idmateriujian}", response_model=ResponseBase)
|
| 12 |
+
async def start_ujian(
|
| 13 |
+
idmateriujian: int,
|
| 14 |
+
authorization: str = Header(None),
|
| 15 |
+
db: AsyncSession = Depends(get_db)
|
| 16 |
+
):
|
| 17 |
+
user = get_current_user(authorization)
|
| 18 |
+
talent_id = user["idtalent"]
|
| 19 |
+
|
| 20 |
+
service = ExamService(db)
|
| 21 |
+
result = await service.start_exam_session(talent_id, idmateriujian)
|
| 22 |
+
return ResponseBase(data=result)
|
| 23 |
+
|
| 24 |
+
@router.post("/compare", response_model=ResponseBase)
|
| 25 |
+
async def compare_exam_voice(
|
| 26 |
+
idContent: int = Form(...),
|
| 27 |
+
idUjian: int = Form(...),
|
| 28 |
+
file: UploadFile = File(...),
|
| 29 |
+
db: AsyncSession = Depends(get_db)
|
| 30 |
+
):
|
| 31 |
+
service = ExamService(db)
|
| 32 |
+
audio_bytes = await file.read()
|
| 33 |
+
result = await service.process_answer(idUjian, idContent, audio_bytes)
|
| 34 |
+
return ResponseBase(data=result)
|
| 35 |
+
|
| 36 |
+
@router.get("/result/{idujian}", response_model=ResponseBase[ExamResultResponse])
|
| 37 |
+
async def get_exam_result(
|
| 38 |
+
idujian: int,
|
| 39 |
+
authorization: str = Header(None),
|
| 40 |
+
db: AsyncSession = Depends(get_db)
|
| 41 |
+
):
|
| 42 |
+
# Verify user owns the exam? (Optional but recommended)
|
| 43 |
+
service = ExamService(db)
|
| 44 |
+
result = await service.finish_exam(idujian)
|
| 45 |
+
return ResponseBase(data=result)
|
| 46 |
+
|
| 47 |
+
# Endpoint delete untuk reset ujian (jika user ingin ulang)
|
| 48 |
+
@router.delete("/delete/{idujian}", response_model=ResponseBase)
|
| 49 |
+
async def delete_exam_session(
|
| 50 |
+
idujian: int,
|
| 51 |
+
db: AsyncSession = Depends(get_db)
|
| 52 |
+
):
|
| 53 |
+
# Implementasi delete di repository jika diperlukan
|
| 54 |
+
# Untuk sekarang return success dummy
|
| 55 |
+
return ResponseBase(message="Exam session reset")
|
app/api/v1/endpoints/history.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.repositories.history_repository import HistoryRepository
|
| 5 |
+
from app.schemas.response import ResponseBase
|
| 6 |
+
from app.utils.time_utils import TimeUtils
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.get("/phoneme", response_model=ResponseBase)
|
| 11 |
+
async def get_phoneme_history(
|
| 12 |
+
page: int = 1, size: int = 10,
|
| 13 |
+
db: AsyncSession = Depends(get_db)
|
| 14 |
+
):
|
| 15 |
+
talent_id = 1
|
| 16 |
+
repo = HistoryRepository(db)
|
| 17 |
+
items = await repo.get_phoneme_history(talent_id, skip=(page-1)*size, limit=size)
|
| 18 |
+
|
| 19 |
+
data = []
|
| 20 |
+
for item in items:
|
| 21 |
+
raw = item["raw"]
|
| 22 |
+
data.append({
|
| 23 |
+
"idsoal": raw.idsoal,
|
| 24 |
+
"typelatihan": raw.typelatihan,
|
| 25 |
+
"soal": item["soal_text"],
|
| 26 |
+
"nilai": raw.nilai,
|
| 27 |
+
"waktulatihan": TimeUtils.format_to_wib(raw.waktulatihan),
|
| 28 |
+
"phoneme_comparison": raw.phoneme_comparison
|
| 29 |
+
})
|
| 30 |
+
return ResponseBase(data=data)
|
| 31 |
+
|
| 32 |
+
@router.get("/conversation", response_model=ResponseBase)
|
| 33 |
+
async def get_conversation_history(
|
| 34 |
+
db: AsyncSession = Depends(get_db)
|
| 35 |
+
):
|
| 36 |
+
talent_id = 1
|
| 37 |
+
repo = HistoryRepository(db)
|
| 38 |
+
rows = await repo.get_conversation_history(talent_id)
|
| 39 |
+
|
| 40 |
+
data = []
|
| 41 |
+
for model, topic in rows:
|
| 42 |
+
data.append({
|
| 43 |
+
"topic": topic,
|
| 44 |
+
"wpm": model.wpm,
|
| 45 |
+
"grammar": model.grammar,
|
| 46 |
+
"waktulatihan": TimeUtils.format_to_wib(model.waktulatihan)
|
| 47 |
+
})
|
| 48 |
+
return ResponseBase(data=data)
|
| 49 |
+
|
| 50 |
+
@router.get("/exam", response_model=ResponseBase)
|
| 51 |
+
async def get_exam_history(
|
| 52 |
+
db: AsyncSession = Depends(get_db)
|
| 53 |
+
):
|
| 54 |
+
talent_id = 1
|
| 55 |
+
repo = HistoryRepository(db)
|
| 56 |
+
history_data = await repo.get_exam_history(talent_id)
|
| 57 |
+
|
| 58 |
+
formatted_data = []
|
| 59 |
+
for h in history_data:
|
| 60 |
+
exam = h["exam"]
|
| 61 |
+
formatted_data.append({
|
| 62 |
+
"idujian": exam.idujian,
|
| 63 |
+
"kategori": exam.kategori,
|
| 64 |
+
"nilai_total": exam.nilai,
|
| 65 |
+
"waktuujian": TimeUtils.format_to_wib(exam.waktuujian),
|
| 66 |
+
"details": h["details"]
|
| 67 |
+
})
|
| 68 |
+
return ResponseBase(data=formatted_data)
|
app/api/v1/endpoints/interview_flow.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Request
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.interview_service import InterviewService
|
| 5 |
+
from app.schemas.response import ResponseBase
|
| 6 |
+
from app.schemas.conversation import ChatInput
|
| 7 |
+
from app.api.deps import get_current_user
|
| 8 |
+
import uuid
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.get("/start", response_model=ResponseBase)
|
| 13 |
+
async def start_interview(
|
| 14 |
+
request: Request,
|
| 15 |
+
db: AsyncSession = Depends(get_db)
|
| 16 |
+
):
|
| 17 |
+
service = InterviewService(db)
|
| 18 |
+
session_id = str(uuid.uuid4())
|
| 19 |
+
result = await service.start_session(session_id)
|
| 20 |
+
|
| 21 |
+
return ResponseBase(data=result)
|
| 22 |
+
|
| 23 |
+
@router.get("/status", response_model=ResponseBase)
|
| 24 |
+
async def get_interview_status(
|
| 25 |
+
request: Request,
|
| 26 |
+
db: AsyncSession = Depends(get_db)
|
| 27 |
+
):
|
| 28 |
+
"""Resume session if exists"""
|
| 29 |
+
service = InterviewService(db)
|
| 30 |
+
session_id = request.headers.get("X-Session-ID")
|
| 31 |
+
if not session_id:
|
| 32 |
+
return ResponseBase(success=False, message="Missing Session ID")
|
| 33 |
+
|
| 34 |
+
result = await service.get_session_status(session_id)
|
| 35 |
+
return ResponseBase(data=result.get("status"), success=result["success"])
|
| 36 |
+
|
| 37 |
+
@router.post("/answer", response_model=ResponseBase)
|
| 38 |
+
async def answer_question(
|
| 39 |
+
request: Request,
|
| 40 |
+
input_data: ChatInput,
|
| 41 |
+
db: AsyncSession = Depends(get_db)
|
| 42 |
+
):
|
| 43 |
+
service = InterviewService(db)
|
| 44 |
+
session_id = request.headers.get("X-Session-ID")
|
| 45 |
+
if not session_id:
|
| 46 |
+
return ResponseBase(success=False, message="Missing X-Session-ID header")
|
| 47 |
+
|
| 48 |
+
result = await service.process_answer(session_id, input_data.user_input, input_data.duration)
|
| 49 |
+
return ResponseBase(data=result)
|
| 50 |
+
|
| 51 |
+
@router.post("/summary", response_model=ResponseBase)
|
| 52 |
+
async def get_summary(
|
| 53 |
+
request: Request,
|
| 54 |
+
current_user: dict = Depends(get_current_user),
|
| 55 |
+
db: AsyncSession = Depends(get_db)
|
| 56 |
+
):
|
| 57 |
+
service = InterviewService(db)
|
| 58 |
+
session_id = request.headers.get("X-Session-ID")
|
| 59 |
+
if not session_id:
|
| 60 |
+
return ResponseBase(success=False, message="Missing X-Session-ID header")
|
| 61 |
+
|
| 62 |
+
result = await service.generate_summary(session_id, current_user["idtalent"])
|
| 63 |
+
return ResponseBase(data=result)
|
app/api/v1/endpoints/material.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, UploadFile, File, Form, status, Path, Response, Query
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from app.core.database import get_db
|
| 5 |
+
from app.services.material_service import MaterialService
|
| 6 |
+
from app.repositories.material_repository import MaterialRepository
|
| 7 |
+
from app.schemas.material import (
|
| 8 |
+
PhonemeWordCreate, PhonemeWordUpdate,
|
| 9 |
+
PhonemeSentenceCreate, PhonemeSentenceUpdate,
|
| 10 |
+
ExamCreate, ExamUpdate,
|
| 11 |
+
InterviewQuestionCreate, InterviewQuestionUpdate, InterviewQuestionResponse,
|
| 12 |
+
SwapOrderRequest
|
| 13 |
+
)
|
| 14 |
+
from app.schemas.response import ResponseBase
|
| 15 |
+
from app.utils.time_utils import TimeUtils
|
| 16 |
+
from app.utils.template_generator import TemplateGenerator
|
| 17 |
+
from app.api.deps import get_current_admin_user
|
| 18 |
+
|
| 19 |
+
router = APIRouter(dependencies=[Depends(get_current_admin_user)])
|
| 20 |
+
|
| 21 |
+
# --- TEMPLATE DOWNLOAD ENDPOINTS ---
|
| 22 |
+
@router.get("/phoneme-material/import-template")
|
| 23 |
+
async def get_word_template():
|
| 24 |
+
buffer = TemplateGenerator.get_phoneme_word_template()
|
| 25 |
+
return StreamingResponse(
|
| 26 |
+
buffer,
|
| 27 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 28 |
+
headers={"Content-Disposition": "attachment; filename=phoneme_word_template.xlsx"}
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
@router.get("/exercise-phoneme/import-template")
|
| 32 |
+
async def get_sentence_template():
|
| 33 |
+
buffer = TemplateGenerator.get_phoneme_sentence_template()
|
| 34 |
+
return StreamingResponse(
|
| 35 |
+
buffer,
|
| 36 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 37 |
+
headers={"Content-Disposition": "attachment; filename=phoneme_sentence_template.xlsx"}
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
@router.get("/exam-phoneme/import-template")
|
| 41 |
+
async def get_exam_template():
|
| 42 |
+
buffer = TemplateGenerator.get_phoneme_exam_template()
|
| 43 |
+
return StreamingResponse(
|
| 44 |
+
buffer,
|
| 45 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 46 |
+
headers={"Content-Disposition": "attachment; filename=phoneme_exam_template.xlsx"}
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
@router.get("/interview-questions/import-template")
|
| 50 |
+
async def get_interview_template():
|
| 51 |
+
buffer = TemplateGenerator.get_phoneme_word_template()
|
| 52 |
+
return StreamingResponse(
|
| 53 |
+
buffer,
|
| 54 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 55 |
+
headers={"Content-Disposition": "attachment; filename=interview_questions_template.xlsx"}
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# =======================
|
| 59 |
+
# PHONEME WORDS
|
| 60 |
+
# =======================
|
| 61 |
+
|
| 62 |
+
@router.get("/phoneme-material", response_model=ResponseBase)
|
| 63 |
+
async def get_phoneme_materials(
|
| 64 |
+
search: str = Query(None),
|
| 65 |
+
page: int = Query(1, ge=1),
|
| 66 |
+
limit: int = Query(10, ge=1),
|
| 67 |
+
db: AsyncSession = Depends(get_db)
|
| 68 |
+
):
|
| 69 |
+
service = MaterialService(MaterialRepository(db))
|
| 70 |
+
result = await service.get_phoneme_materials_list(page, limit, search)
|
| 71 |
+
return ResponseBase(data=result)
|
| 72 |
+
|
| 73 |
+
@router.post("/phoneme-material", response_model=ResponseBase)
|
| 74 |
+
async def add_word(request: PhonemeWordCreate, db: AsyncSession = Depends(get_db)):
|
| 75 |
+
repo = MaterialRepository(db)
|
| 76 |
+
data = {
|
| 77 |
+
"kategori": request.phoneme_category, "kata": request.word,
|
| 78 |
+
"meaning": request.meaning, "definition": request.word_definition,
|
| 79 |
+
"fonem": request.phoneme
|
| 80 |
+
}
|
| 81 |
+
result = await repo.create_word(data)
|
| 82 |
+
return ResponseBase(message="Word added", data={"id": result.idmaterifonemkata})
|
| 83 |
+
|
| 84 |
+
@router.put("/phoneme-material/words/{word_id}", response_model=ResponseBase)
|
| 85 |
+
async def update_word(word_id: int, request: PhonemeWordUpdate, db: AsyncSession = Depends(get_db)):
|
| 86 |
+
service = MaterialService(MaterialRepository(db))
|
| 87 |
+
data = {}
|
| 88 |
+
if request.word: data["kata"] = request.word
|
| 89 |
+
if request.meaning: data["meaning"] = request.meaning
|
| 90 |
+
if request.word_definition: data["definition"] = request.word_definition
|
| 91 |
+
if request.phoneme: data["fonem"] = request.phoneme
|
| 92 |
+
|
| 93 |
+
await service.update_word(word_id, data)
|
| 94 |
+
return ResponseBase(message="Word updated")
|
| 95 |
+
|
| 96 |
+
@router.delete("/phoneme-material/words/{word_id}", response_model=ResponseBase)
|
| 97 |
+
async def delete_word(word_id: int, db: AsyncSession = Depends(get_db)):
|
| 98 |
+
service = MaterialService(MaterialRepository(db))
|
| 99 |
+
await service.delete_word(word_id)
|
| 100 |
+
return ResponseBase(message="Word deleted")
|
| 101 |
+
|
| 102 |
+
@router.post("/phoneme-material/import", response_model=ResponseBase)
|
| 103 |
+
async def import_words(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
| 104 |
+
service = MaterialService(MaterialRepository(db))
|
| 105 |
+
content = await file.read()
|
| 106 |
+
result = await service.import_words_from_excel(content)
|
| 107 |
+
return ResponseBase(message="Import completed", data=result)
|
| 108 |
+
|
| 109 |
+
@router.get("/phoneme-material/{category}/detail", response_model=ResponseBase)
|
| 110 |
+
async def get_words_by_category_detail(
|
| 111 |
+
category: str,
|
| 112 |
+
page: int = 1,
|
| 113 |
+
limit: int = 10,
|
| 114 |
+
db: AsyncSession = Depends(get_db)
|
| 115 |
+
):
|
| 116 |
+
repo = MaterialRepository(db)
|
| 117 |
+
all_words = await repo.get_words_by_category(category)
|
| 118 |
+
total = len(all_words)
|
| 119 |
+
start = (page - 1) * limit
|
| 120 |
+
end = start + limit
|
| 121 |
+
paginated_words = all_words[start:end]
|
| 122 |
+
|
| 123 |
+
data = []
|
| 124 |
+
for w in paginated_words:
|
| 125 |
+
data.append({
|
| 126 |
+
"id": w.idmaterifonemkata,
|
| 127 |
+
"word": w.kata,
|
| 128 |
+
"meaning": w.meaning,
|
| 129 |
+
"definition": w.definition,
|
| 130 |
+
"phoneme": w.fonem
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
return ResponseBase(data={
|
| 134 |
+
"data": data,
|
| 135 |
+
"pagination": {
|
| 136 |
+
"currentPage": page,
|
| 137 |
+
"totalRecords": total,
|
| 138 |
+
"totalPages": (total + limit - 1) // limit
|
| 139 |
+
}
|
| 140 |
+
})
|
| 141 |
+
|
| 142 |
+
# =======================
|
| 143 |
+
# PHONEME SENTENCES
|
| 144 |
+
# =======================
|
| 145 |
+
|
| 146 |
+
@router.get("/exercise-phoneme", response_model=ResponseBase)
|
| 147 |
+
async def get_exercise_materials(
|
| 148 |
+
search: str = Query(None),
|
| 149 |
+
page: int = Query(1, ge=1),
|
| 150 |
+
limit: int = Query(10, ge=1),
|
| 151 |
+
db: AsyncSession = Depends(get_db)
|
| 152 |
+
):
|
| 153 |
+
service = MaterialService(MaterialRepository(db))
|
| 154 |
+
result = await service.get_exercise_materials_list(page, limit, search)
|
| 155 |
+
return ResponseBase(data=result)
|
| 156 |
+
|
| 157 |
+
@router.post("/exercise-phoneme/sentences", response_model=ResponseBase)
|
| 158 |
+
async def add_sentence(request: PhonemeSentenceCreate, db: AsyncSession = Depends(get_db)):
|
| 159 |
+
service = MaterialService(MaterialRepository(db))
|
| 160 |
+
data = {
|
| 161 |
+
"kategori": request.phoneme_category,
|
| 162 |
+
"kalimat": request.sentence,
|
| 163 |
+
"fonem": request.phoneme
|
| 164 |
+
}
|
| 165 |
+
result = await service.create_sentence(data)
|
| 166 |
+
return ResponseBase(message="Sentence added", data={"id": result.idmaterifonemkalimat})
|
| 167 |
+
|
| 168 |
+
@router.put("/exercise-phoneme/sentences/{sentence_id}", response_model=ResponseBase)
|
| 169 |
+
async def update_sentence(sentence_id: int, request: PhonemeSentenceUpdate, db: AsyncSession = Depends(get_db)):
|
| 170 |
+
service = MaterialService(MaterialRepository(db))
|
| 171 |
+
data = {}
|
| 172 |
+
if request.sentence: data["kalimat"] = request.sentence
|
| 173 |
+
if request.phoneme: data["fonem"] = request.phoneme
|
| 174 |
+
await service.update_sentence(sentence_id, data)
|
| 175 |
+
return ResponseBase(message="Sentence updated")
|
| 176 |
+
|
| 177 |
+
@router.delete("/exercise-phoneme/sentences/{sentence_id}", response_model=ResponseBase)
|
| 178 |
+
async def delete_sentence(sentence_id: int, db: AsyncSession = Depends(get_db)):
|
| 179 |
+
service = MaterialService(MaterialRepository(db))
|
| 180 |
+
await service.delete_sentence(sentence_id)
|
| 181 |
+
return ResponseBase(message="Sentence deleted")
|
| 182 |
+
|
| 183 |
+
@router.post("/exercise-phoneme/import", response_model=ResponseBase)
|
| 184 |
+
async def import_sentences(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
| 185 |
+
service = MaterialService(MaterialRepository(db))
|
| 186 |
+
content = await file.read()
|
| 187 |
+
result = await service.import_sentences_from_excel(content)
|
| 188 |
+
return ResponseBase(message="Import completed", data=result)
|
| 189 |
+
|
| 190 |
+
@router.get("/exercise-phoneme/{category}/detail", response_model=ResponseBase)
|
| 191 |
+
async def get_sentences_by_category_detail(
|
| 192 |
+
category: str,
|
| 193 |
+
page: int = 1,
|
| 194 |
+
limit: int = 10,
|
| 195 |
+
db: AsyncSession = Depends(get_db)
|
| 196 |
+
):
|
| 197 |
+
repo = MaterialRepository(db)
|
| 198 |
+
all_sentences = await repo.get_sentences_by_category(category)
|
| 199 |
+
total = len(all_sentences)
|
| 200 |
+
start = (page - 1) * limit
|
| 201 |
+
end = start + limit
|
| 202 |
+
paginated = all_sentences[start:end]
|
| 203 |
+
|
| 204 |
+
data = []
|
| 205 |
+
for s in paginated:
|
| 206 |
+
data.append({
|
| 207 |
+
"id": s.idmaterifonemkalimat,
|
| 208 |
+
"sentence": s.kalimat,
|
| 209 |
+
"phoneme": s.fonem
|
| 210 |
+
})
|
| 211 |
+
|
| 212 |
+
return ResponseBase(data={
|
| 213 |
+
"data": data,
|
| 214 |
+
"pagination": {
|
| 215 |
+
"currentPage": page,
|
| 216 |
+
"totalRecords": total,
|
| 217 |
+
"totalPages": (total + limit - 1) // limit
|
| 218 |
+
}
|
| 219 |
+
})
|
| 220 |
+
|
| 221 |
+
# =======================
|
| 222 |
+
# EXAMS
|
| 223 |
+
# =======================
|
| 224 |
+
@router.get("/exam-phoneme", response_model=ResponseBase)
|
| 225 |
+
async def get_exam_materials(
|
| 226 |
+
search: str = Query(None),
|
| 227 |
+
page: int = Query(1, ge=1),
|
| 228 |
+
limit: int = Query(10, ge=1),
|
| 229 |
+
db: AsyncSession = Depends(get_db)
|
| 230 |
+
):
|
| 231 |
+
service = MaterialService(MaterialRepository(db))
|
| 232 |
+
result = await service.get_exam_materials_list(page, limit, search)
|
| 233 |
+
return ResponseBase(data=result)
|
| 234 |
+
|
| 235 |
+
@router.post("/exam-phoneme/bulk-sentences", response_model=ResponseBase)
|
| 236 |
+
async def add_exam_bulk(request: ExamCreate, db: AsyncSession = Depends(get_db)):
|
| 237 |
+
repo = MaterialRepository(db)
|
| 238 |
+
service = MaterialService(repo)
|
| 239 |
+
items_dict = [{"sentence": item.sentence, "phoneme": item.phoneme} for item in request.items]
|
| 240 |
+
result = await service.create_exam(request.category, items_dict)
|
| 241 |
+
return ResponseBase(message="Exam created", data={"examId": result.idmateriujian})
|
| 242 |
+
|
| 243 |
+
@router.put("/exam-phoneme/{phoneme_category}/tests/{test_id}", response_model=ResponseBase)
|
| 244 |
+
async def update_exam(phoneme_category: str, test_id: str, request: ExamUpdate, db: AsyncSession = Depends(get_db)):
|
| 245 |
+
service = MaterialService(MaterialRepository(db))
|
| 246 |
+
try:
|
| 247 |
+
exam_id = int(test_id.replace("EXM", ""))
|
| 248 |
+
except ValueError:
|
| 249 |
+
exam_id = int(test_id)
|
| 250 |
+
|
| 251 |
+
await service.update_exam_sentences(exam_id, request.sentences)
|
| 252 |
+
return ResponseBase(message="Exam updated")
|
| 253 |
+
|
| 254 |
+
@router.delete("/exam-phoneme/{phoneme_category}/tests/{test_id}", response_model=ResponseBase)
|
| 255 |
+
async def delete_exam(phoneme_category: str, test_id: str, db: AsyncSession = Depends(get_db)):
|
| 256 |
+
service = MaterialService(MaterialRepository(db))
|
| 257 |
+
try:
|
| 258 |
+
exam_id = int(test_id.replace("EXM", ""))
|
| 259 |
+
except ValueError:
|
| 260 |
+
exam_id = int(test_id)
|
| 261 |
+
await service.delete_exam(exam_id)
|
| 262 |
+
return ResponseBase(message="Exam deleted")
|
| 263 |
+
|
| 264 |
+
@router.post("/exam-phoneme/import", response_model=ResponseBase)
|
| 265 |
+
async def import_exams(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
| 266 |
+
service = MaterialService(MaterialRepository(db))
|
| 267 |
+
content = await file.read()
|
| 268 |
+
result = await service.import_exams_from_excel(content)
|
| 269 |
+
return ResponseBase(message="Import completed", data=result)
|
| 270 |
+
|
| 271 |
+
@router.get("/exam-phoneme/{category}/detail", response_model=ResponseBase)
|
| 272 |
+
async def get_exam_by_category_detail(
|
| 273 |
+
category: str,
|
| 274 |
+
page: int = 1,
|
| 275 |
+
limit: int = 10,
|
| 276 |
+
db: AsyncSession = Depends(get_db)
|
| 277 |
+
):
|
| 278 |
+
from sqlalchemy import select, func
|
| 279 |
+
from app.models.models import Materiujian, Materiujiankalimat
|
| 280 |
+
|
| 281 |
+
q = select(Materiujian).where(Materiujian.kategori == category)
|
| 282 |
+
result = await db.execute(q)
|
| 283 |
+
exams = result.scalars().all()
|
| 284 |
+
|
| 285 |
+
total = len(exams)
|
| 286 |
+
start = (page - 1) * limit
|
| 287 |
+
end = start + limit
|
| 288 |
+
paginated = exams[start:end]
|
| 289 |
+
|
| 290 |
+
data = []
|
| 291 |
+
for idx, ex in enumerate(paginated):
|
| 292 |
+
q_count = select(func.count(Materiujiankalimat.idmateriujiankalimat)).where(Materiujiankalimat.idmateriujian == ex.idmateriujian)
|
| 293 |
+
count = await db.scalar(q_count)
|
| 294 |
+
|
| 295 |
+
data.append({
|
| 296 |
+
"exam_id": ex.idmateriujian,
|
| 297 |
+
"test_number": f"Test {start + idx + 1}",
|
| 298 |
+
"total_sentence": count,
|
| 299 |
+
"last_update": TimeUtils.format_to_wib(ex.updated_at)
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
return ResponseBase(data={
|
| 303 |
+
"exams": data,
|
| 304 |
+
"pagination": {
|
| 305 |
+
"currentPage": page,
|
| 306 |
+
"totalRecords": total,
|
| 307 |
+
"totalPages": (total + limit - 1) // limit
|
| 308 |
+
}
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
@router.get("/exam-phoneme/{category}/tests/{test_id}/sentences", response_model=ResponseBase)
|
| 312 |
+
async def get_exam_sentences(
|
| 313 |
+
category: str,
|
| 314 |
+
test_id: int,
|
| 315 |
+
db: AsyncSession = Depends(get_db)
|
| 316 |
+
):
|
| 317 |
+
from sqlalchemy import select
|
| 318 |
+
from app.models.models import Materiujiankalimat
|
| 319 |
+
|
| 320 |
+
q = select(Materiujiankalimat).where(Materiujiankalimat.idmateriujian == test_id)
|
| 321 |
+
result = await db.execute(q)
|
| 322 |
+
sentences = result.scalars().all()
|
| 323 |
+
|
| 324 |
+
data = []
|
| 325 |
+
for s in sentences:
|
| 326 |
+
data.append({
|
| 327 |
+
"id_sentence": s.idmateriujiankalimat,
|
| 328 |
+
"sentence": s.kalimat,
|
| 329 |
+
"phoneme": s.fonem
|
| 330 |
+
})
|
| 331 |
+
|
| 332 |
+
return ResponseBase(data={
|
| 333 |
+
"testInfo": {"testId": test_id, "phonemeCategory": category},
|
| 334 |
+
"sentences": data
|
| 335 |
+
})
|
| 336 |
+
|
| 337 |
+
# =======================
|
| 338 |
+
# INTERVIEW QUESTIONS
|
| 339 |
+
# =======================
|
| 340 |
+
@router.get("/interview-questions", response_model=ResponseBase)
|
| 341 |
+
async def get_interview_questions(page: int = 1, size: int = 10, db: AsyncSession = Depends(get_db)):
|
| 342 |
+
repo = MaterialRepository(db)
|
| 343 |
+
skip = (page - 1) * size
|
| 344 |
+
questions = await repo.get_interview_questions(skip, size)
|
| 345 |
+
|
| 346 |
+
data = [
|
| 347 |
+
InterviewQuestionResponse(
|
| 348 |
+
questionId=f"QST{q.idmateriinterview:03d}",
|
| 349 |
+
interviewQuestion=q.question,
|
| 350 |
+
isActive=q.is_active,
|
| 351 |
+
createdAt=TimeUtils.format_to_wib(q.updated_at),
|
| 352 |
+
orderPosition=idx + 1 + skip
|
| 353 |
+
) for idx, q in enumerate(questions)
|
| 354 |
+
]
|
| 355 |
+
return ResponseBase(data={"interviewQuestions": data})
|
| 356 |
+
|
| 357 |
+
@router.get("/interview-questions/mobile-order", response_model=ResponseBase)
|
| 358 |
+
async def get_mobile_order(limit: int = None, db: AsyncSession = Depends(get_db)):
|
| 359 |
+
from sqlalchemy import select
|
| 360 |
+
from app.models.models import Materiinterview
|
| 361 |
+
|
| 362 |
+
q = select(Materiinterview).where(Materiinterview.is_active == True).order_by(Materiinterview.idmateriinterview.asc())
|
| 363 |
+
if limit:
|
| 364 |
+
q = q.limit(limit)
|
| 365 |
+
|
| 366 |
+
result = await db.execute(q)
|
| 367 |
+
questions = result.scalars().all()
|
| 368 |
+
|
| 369 |
+
data = [{"id": q.idmateriinterview, "question": q.question} for q in questions]
|
| 370 |
+
return ResponseBase(data={"questions": data})
|
| 371 |
+
|
| 372 |
+
@router.post("/interview-questions", response_model=ResponseBase)
|
| 373 |
+
async def add_interview_question(request: InterviewQuestionCreate, db: AsyncSession = Depends(get_db)):
|
| 374 |
+
repo = MaterialRepository(db)
|
| 375 |
+
result = await repo.create_interview_question(request.interview_question)
|
| 376 |
+
return ResponseBase(status_code=status.HTTP_201_CREATED, message="Question added", data={"id": result.idmateriinterview})
|
| 377 |
+
|
| 378 |
+
@router.put("/interview-questions/{question_id}", response_model=ResponseBase)
|
| 379 |
+
async def update_interview_question(question_id: int, request: InterviewQuestionUpdate, db: AsyncSession = Depends(get_db)):
|
| 380 |
+
service = MaterialService(MaterialRepository(db))
|
| 381 |
+
await service.update_interview(question_id, request.interview_question)
|
| 382 |
+
return ResponseBase(message="Question updated")
|
| 383 |
+
|
| 384 |
+
@router.delete("/interview-questions/{question_id}", response_model=ResponseBase)
|
| 385 |
+
async def delete_interview_question(question_id: int, db: AsyncSession = Depends(get_db)):
|
| 386 |
+
service = MaterialService(MaterialRepository(db))
|
| 387 |
+
await service.delete_interview(question_id)
|
| 388 |
+
return ResponseBase(message="Question deleted")
|
| 389 |
+
|
| 390 |
+
@router.post("/interview-questions/{question_id}/toggle", response_model=ResponseBase)
|
| 391 |
+
async def toggle_interview_status(question_id: int, db: AsyncSession = Depends(get_db)):
|
| 392 |
+
service = MaterialService(MaterialRepository(db))
|
| 393 |
+
result = await service.toggle_interview(question_id)
|
| 394 |
+
return ResponseBase(message="Status toggled", data={"isActive": result.is_active})
|
| 395 |
+
|
| 396 |
+
@router.post("/interview-questions/{question_id}/swap", response_model=ResponseBase)
|
| 397 |
+
async def swap_interview_order(question_id: int, request: SwapOrderRequest, db: AsyncSession = Depends(get_db)):
|
| 398 |
+
service = MaterialService(MaterialRepository(db))
|
| 399 |
+
await service.swap_interview(question_id, request.direction)
|
| 400 |
+
return ResponseBase(message=f"Swapped {request.direction}")
|
| 401 |
+
|
| 402 |
+
@router.post("/interview-questions/import", response_model=ResponseBase)
|
| 403 |
+
async def import_interview_questions(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
| 404 |
+
service = MaterialService(MaterialRepository(db))
|
| 405 |
+
content = await file.read()
|
| 406 |
+
result = await service.import_interview_questions_from_excel(content)
|
| 407 |
+
return ResponseBase(message="Import completed", data=result)
|
app/api/v1/endpoints/mobile_profile.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.profile_service import ProfileService
|
| 5 |
+
from app.schemas.response import ResponseBase
|
| 6 |
+
from app.api.deps import get_current_user
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
@router.get("/summary", response_model=ResponseBase)
|
| 11 |
+
async def get_profile_summary(
|
| 12 |
+
current_user: dict = Depends(get_current_user),
|
| 13 |
+
db: AsyncSession = Depends(get_db)
|
| 14 |
+
):
|
| 15 |
+
service = ProfileService(db)
|
| 16 |
+
data = await service.get_mobile_profile(current_user["idtalent"])
|
| 17 |
+
return ResponseBase(data=data)
|
app/api/v1/endpoints/phoneme.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, UploadFile, File, Form, Path
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.services.phoneme_service import PhonemeService
|
| 5 |
+
from app.repositories.material_repository import MaterialRepository
|
| 6 |
+
from app.repositories.score_repository import ScoreRepository
|
| 7 |
+
from app.schemas.response import ResponseBase
|
| 8 |
+
from app.schemas.phoneme import PhonemeCheckResponse
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
@router.post("/compare", response_model=ResponseBase[PhonemeCheckResponse])
|
| 13 |
+
async def compare_phonemes(
|
| 14 |
+
idContent: int = Form(...),
|
| 15 |
+
type: str = Form("sentence"),
|
| 16 |
+
file: UploadFile = File(...),
|
| 17 |
+
db: AsyncSession = Depends(get_db)
|
| 18 |
+
):
|
| 19 |
+
talent_id = 1 # TODO: Auth
|
| 20 |
+
mat_repo = MaterialRepository(db)
|
| 21 |
+
score_repo = ScoreRepository(db)
|
| 22 |
+
service = PhonemeService(mat_repo, score_repo)
|
| 23 |
+
|
| 24 |
+
audio_content = await file.read()
|
| 25 |
+
result = await service.process_pronunciation(talent_id, idContent, audio_content, type)
|
| 26 |
+
return ResponseBase(message="Analysis completed", data=PhonemeCheckResponse(**result))
|
| 27 |
+
|
| 28 |
+
@router.post("/compare_word", response_model=ResponseBase[PhonemeCheckResponse])
|
| 29 |
+
async def compare_phonemes_word(
|
| 30 |
+
idContent: int = Form(...),
|
| 31 |
+
file: UploadFile = File(...),
|
| 32 |
+
db: AsyncSession = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
"""Alias khusus untuk compare word (mobile legacy)"""
|
| 35 |
+
return await compare_phonemes(idContent=idContent, type="word", file=file, db=db)
|
| 36 |
+
|
| 37 |
+
# --- MISSING ENDPOINTS RESTORED ---
|
| 38 |
+
|
| 39 |
+
@router.get("/word_by_id/{id}", response_model=ResponseBase)
|
| 40 |
+
async def get_word_by_id(id: int, db: AsyncSession = Depends(get_db)):
|
| 41 |
+
mat_repo = MaterialRepository(db)
|
| 42 |
+
score_repo = ScoreRepository(db)
|
| 43 |
+
service = PhonemeService(mat_repo, score_repo)
|
| 44 |
+
data = await service.get_word_by_id(id)
|
| 45 |
+
return ResponseBase(data=data)
|
| 46 |
+
|
| 47 |
+
@router.get("/sentence_by_id/{id}", response_model=ResponseBase)
|
| 48 |
+
async def get_sentence_by_id(id: int, db: AsyncSession = Depends(get_db)):
|
| 49 |
+
mat_repo = MaterialRepository(db)
|
| 50 |
+
score_repo = ScoreRepository(db)
|
| 51 |
+
service = PhonemeService(mat_repo, score_repo)
|
| 52 |
+
data = await service.get_sentence_by_id(id)
|
| 53 |
+
return ResponseBase(data=data)
|
| 54 |
+
|
| 55 |
+
@router.get("/random_word/{phoneme}", response_model=ResponseBase)
|
| 56 |
+
async def get_random_word(phoneme: str, db: AsyncSession = Depends(get_db)):
|
| 57 |
+
mat_repo = MaterialRepository(db)
|
| 58 |
+
score_repo = ScoreRepository(db)
|
| 59 |
+
service = PhonemeService(mat_repo, score_repo)
|
| 60 |
+
data = await service.get_random_word(phoneme)
|
| 61 |
+
if not data: return ResponseBase(success=False, message="No data found")
|
| 62 |
+
return ResponseBase(data=data)
|
| 63 |
+
|
| 64 |
+
@router.get("/random_sentence/{phoneme}", response_model=ResponseBase)
|
| 65 |
+
async def get_random_sentence(phoneme: str, db: AsyncSession = Depends(get_db)):
|
| 66 |
+
mat_repo = MaterialRepository(db)
|
| 67 |
+
score_repo = ScoreRepository(db)
|
| 68 |
+
service = PhonemeService(mat_repo, score_repo)
|
| 69 |
+
data = await service.get_random_sentence(phoneme)
|
| 70 |
+
if not data: return ResponseBase(success=False, message="No data found")
|
| 71 |
+
return ResponseBase(data=data)
|
| 72 |
+
|
| 73 |
+
# --- CATEGORY LISTS (Mobile) ---
|
| 74 |
+
@router.get("/phoneme_words_categories", response_model=ResponseBase)
|
| 75 |
+
async def get_word_categories(db: AsyncSession = Depends(get_db)):
|
| 76 |
+
repo = MaterialRepository(db)
|
| 77 |
+
categories = await repo.get_all_word_categories()
|
| 78 |
+
return ResponseBase(data={"categories": categories})
|
| 79 |
+
|
| 80 |
+
@router.get("/phoneme_sentences_categories", response_model=ResponseBase)
|
| 81 |
+
async def get_sentence_categories(db: AsyncSession = Depends(get_db)):
|
| 82 |
+
repo = MaterialRepository(db)
|
| 83 |
+
categories = await repo.get_all_sentence_categories()
|
| 84 |
+
return ResponseBase(data={"categories": categories})
|
| 85 |
+
|
| 86 |
+
@router.get("/words_by_category/{category}", response_model=ResponseBase)
|
| 87 |
+
async def get_words_by_category(category: str, db: AsyncSession = Depends(get_db)):
|
| 88 |
+
repo = MaterialRepository(db)
|
| 89 |
+
words = await repo.get_words_by_category(category)
|
| 90 |
+
data = [{
|
| 91 |
+
"idContent": w.idmaterifonemkata, "content": w.kata,
|
| 92 |
+
"phoneme": w.fonem, "phoneme_category": w.kategori
|
| 93 |
+
} for w in words]
|
| 94 |
+
return ResponseBase(data={"category": category, "words": data})
|
| 95 |
+
|
| 96 |
+
@router.get("/sentences_by_category/{category}", response_model=ResponseBase)
|
| 97 |
+
async def get_sentences_by_category(category: str, db: AsyncSession = Depends(get_db)):
|
| 98 |
+
repo = MaterialRepository(db)
|
| 99 |
+
sentences = await repo.get_sentences_by_category(category)
|
| 100 |
+
data = [{
|
| 101 |
+
"idContent": s.idmaterifonemkalimat, "content": s.kalimat,
|
| 102 |
+
"phoneme": s.fonem, "phoneme_category": s.kategori
|
| 103 |
+
} for s in sentences]
|
| 104 |
+
return ResponseBase(data={"category": category, "sentences": data})
|
app/api/v1/endpoints/pretest.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, UploadFile, File, Form, Header
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.core.database import get_db
|
| 4 |
+
from app.repositories.material_repository import MaterialRepository
|
| 5 |
+
from app.repositories.talent_repository import TalentRepository
|
| 6 |
+
from app.services.phoneme_service import PhonemeService
|
| 7 |
+
from app.services.audio_service import AudioService
|
| 8 |
+
from app.utils.phoneme_utils import PhonemeMatcher
|
| 9 |
+
from app.schemas.response import ResponseBase
|
| 10 |
+
from app.api.deps import get_current_user
|
| 11 |
+
from app.models.models import Talent
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
@router.get("/check", response_model=ResponseBase)
|
| 16 |
+
async def check_onboarding(
|
| 17 |
+
current_user: dict = Depends(get_current_user),
|
| 18 |
+
db: AsyncSession = Depends(get_db)
|
| 19 |
+
):
|
| 20 |
+
"""Cek apakah user sudah pretest"""
|
| 21 |
+
talent_repo = TalentRepository(db)
|
| 22 |
+
talent = await talent_repo.get_by_id(current_user["idtalent"])
|
| 23 |
+
show_onboarding = talent.pretest_score is None if talent else False
|
| 24 |
+
return ResponseBase(data={"show_onboarding": show_onboarding})
|
| 25 |
+
|
| 26 |
+
@router.get("/start", response_model=ResponseBase)
|
| 27 |
+
async def start_pretest(db: AsyncSession = Depends(get_db)):
|
| 28 |
+
"""Ambil 10 soal acak untuk pretest"""
|
| 29 |
+
repo = MaterialRepository(db)
|
| 30 |
+
questions = await repo.get_random_sentences(limit=10)
|
| 31 |
+
data = [
|
| 32 |
+
{
|
| 33 |
+
"id": q.idmaterifonemkalimat,
|
| 34 |
+
"kategori": q.kategori,
|
| 35 |
+
"kalimat": q.kalimat,
|
| 36 |
+
"fonem": q.fonem
|
| 37 |
+
} for q in questions
|
| 38 |
+
]
|
| 39 |
+
return ResponseBase(data=data)
|
| 40 |
+
|
| 41 |
+
@router.post("/compare", response_model=ResponseBase)
|
| 42 |
+
async def compare_pretest(
|
| 43 |
+
idContent: int = Form(...),
|
| 44 |
+
file: UploadFile = File(...),
|
| 45 |
+
db: AsyncSession = Depends(get_db)
|
| 46 |
+
):
|
| 47 |
+
"""Analisis audio pretest (tanpa simpan ke DB history, hanya return score)"""
|
| 48 |
+
repo = MaterialRepository(db)
|
| 49 |
+
content = await repo.get_phoneme_content(idContent, "sentence")
|
| 50 |
+
if not content: return ResponseBase(success=False, message="Content not found")
|
| 51 |
+
|
| 52 |
+
audio_bytes = await file.read()
|
| 53 |
+
user_phonemes = await AudioService.transcribe(audio_bytes)
|
| 54 |
+
alignment = PhonemeMatcher.align_phonemes(content.fonem, user_phonemes)
|
| 55 |
+
score = PhonemeMatcher.calculate_accuracy(alignment)
|
| 56 |
+
|
| 57 |
+
return ResponseBase(data={
|
| 58 |
+
"similarity_percent": f"{score}%",
|
| 59 |
+
"phoneme_comparison": alignment,
|
| 60 |
+
"user_phonemes": user_phonemes,
|
| 61 |
+
"target_phonemes": content.fonem
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
@router.post("/submit", response_model=ResponseBase)
|
| 65 |
+
async def submit_pretest(
|
| 66 |
+
payload: dict, # {"score": float}
|
| 67 |
+
current_user: dict = Depends(get_current_user),
|
| 68 |
+
db: AsyncSession = Depends(get_db)
|
| 69 |
+
):
|
| 70 |
+
"""Simpan nilai akhir pretest ke profil talent"""
|
| 71 |
+
talent_repo = TalentRepository(db)
|
| 72 |
+
talent = await talent_repo.get_by_id(current_user["idtalent"])
|
| 73 |
+
if talent:
|
| 74 |
+
await talent_repo.update(talent, {"pretest_score": payload.get("score", 0)})
|
| 75 |
+
return ResponseBase(message="Pretest score saved")
|
| 76 |
+
return ResponseBase(success=False, message="Talent not found")
|
app/api/v1/endpoints/talents.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query, UploadFile, File, HTTPException, status
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from app.core.database import get_db
|
| 5 |
+
from app.services.talent_service import TalentService
|
| 6 |
+
from app.schemas.talent import TalentUpdate, TalentPasswordUpdate
|
| 7 |
+
from app.schemas.auth import TalentCreate
|
| 8 |
+
from app.schemas.response import ResponseBase
|
| 9 |
+
from app.utils.template_generator import TemplateGenerator
|
| 10 |
+
from app.api.deps import get_current_admin_user
|
| 11 |
+
|
| 12 |
+
router = APIRouter(dependencies=[Depends(get_current_admin_user)])
|
| 13 |
+
|
| 14 |
+
@router.get("", response_model=ResponseBase)
|
| 15 |
+
async def get_talent_list(
|
| 16 |
+
searchQuery: str = Query(None),
|
| 17 |
+
page: int = Query(1, ge=1),
|
| 18 |
+
limit: int = Query(10, ge=1),
|
| 19 |
+
db: AsyncSession = Depends(get_db)
|
| 20 |
+
):
|
| 21 |
+
service = TalentService(db)
|
| 22 |
+
result = await service.get_talents_list(page, limit, searchQuery)
|
| 23 |
+
pagination = {
|
| 24 |
+
"currentPage": result["page"],
|
| 25 |
+
"totalRecords": result["total"],
|
| 26 |
+
"totalPages": (result["total"] + limit - 1) // limit
|
| 27 |
+
}
|
| 28 |
+
return ResponseBase(data={"talents": result["data"], "pagination": pagination}, message="Data talent berhasil diambil")
|
| 29 |
+
|
| 30 |
+
@router.post("", response_model=ResponseBase, status_code=status.HTTP_201_CREATED)
|
| 31 |
+
async def create_talent(
|
| 32 |
+
request: TalentCreate,
|
| 33 |
+
db: AsyncSession = Depends(get_db)
|
| 34 |
+
):
|
| 35 |
+
service = TalentService(db)
|
| 36 |
+
result = await service.create_talent(request)
|
| 37 |
+
return ResponseBase(
|
| 38 |
+
message="Talent created successfully",
|
| 39 |
+
data={"id": result.idtalent, "email": result.email}
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
@router.get("/import-template")
|
| 43 |
+
async def get_talent_template():
|
| 44 |
+
buffer = TemplateGenerator.get_talent_template()
|
| 45 |
+
return StreamingResponse(
|
| 46 |
+
buffer,
|
| 47 |
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 48 |
+
headers={"Content-Disposition": "attachment; filename=talent_import_template.xlsx"}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
@router.post("/import", response_model=ResponseBase)
|
| 52 |
+
async def import_talents(file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
| 53 |
+
service = TalentService(db)
|
| 54 |
+
content = await file.read()
|
| 55 |
+
result = await service.import_talents_from_excel(content)
|
| 56 |
+
return ResponseBase(message="Talent import completed", data=result)
|
| 57 |
+
|
| 58 |
+
@router.get("/{talent_id}", response_model=ResponseBase)
|
| 59 |
+
async def get_talent_detail(talent_id: int, db: AsyncSession = Depends(get_db)):
|
| 60 |
+
service = TalentService(db)
|
| 61 |
+
data = await service.get_talent_detail(talent_id)
|
| 62 |
+
return ResponseBase(data=data)
|
| 63 |
+
|
| 64 |
+
@router.put("/{talent_id}", response_model=ResponseBase)
|
| 65 |
+
async def update_talent(
|
| 66 |
+
talent_id: int,
|
| 67 |
+
request: TalentUpdate,
|
| 68 |
+
db: AsyncSession = Depends(get_db)
|
| 69 |
+
):
|
| 70 |
+
service = TalentService(db)
|
| 71 |
+
await service.update_talent(talent_id, request)
|
| 72 |
+
return ResponseBase(message="Data talent berhasil diperbarui")
|
| 73 |
+
|
| 74 |
+
@router.delete("/{talent_id}", response_model=ResponseBase)
|
| 75 |
+
async def delete_talent(talent_id: int, db: AsyncSession = Depends(get_db)):
|
| 76 |
+
service = TalentService(db)
|
| 77 |
+
await service.delete_talent(talent_id)
|
| 78 |
+
return ResponseBase(message="Talent berhasil dihapus")
|
| 79 |
+
|
| 80 |
+
@router.put("/{talent_id}/change-password", response_model=ResponseBase)
|
| 81 |
+
async def change_talent_password(
|
| 82 |
+
talent_id: int,
|
| 83 |
+
request: TalentPasswordUpdate,
|
| 84 |
+
db: AsyncSession = Depends(get_db)
|
| 85 |
+
):
|
| 86 |
+
service = TalentService(db)
|
| 87 |
+
await service.change_password(talent_id, request.new_password)
|
| 88 |
+
return ResponseBase(message="Password berhasil diubah")
|
| 89 |
+
|
| 90 |
+
@router.get("/{talent_id}/phoneme-material-exercise", response_model=ResponseBase)
|
| 91 |
+
async def get_phoneme_word_progress(talent_id: int, page: int = Query(1, ge=1), limit: int = Query(10, ge=1), db: AsyncSession = Depends(get_db)):
|
| 92 |
+
service = TalentService(db)
|
| 93 |
+
result = await service.get_phoneme_progress(talent_id, "Word", page, limit)
|
| 94 |
+
return ResponseBase(data={"phonemeCategories": result["data"], "pagination": {"currentPage": page, "totalRecords": result["total"], "totalPages": (result["total"] + limit - 1) // limit}})
|
| 95 |
+
|
| 96 |
+
@router.get("/{talent_id}/phoneme-exercise", response_model=ResponseBase)
|
| 97 |
+
async def get_phoneme_sentence_progress(talent_id: int, page: int = Query(1, ge=1), limit: int = Query(10, ge=1), db: AsyncSession = Depends(get_db)):
|
| 98 |
+
service = TalentService(db)
|
| 99 |
+
result = await service.get_phoneme_progress(talent_id, "Sentence", page, limit)
|
| 100 |
+
return ResponseBase(data={"phonemeExercises": result["data"], "pagination": {"currentPage": page, "totalRecords": result["total"], "totalPages": (result["total"] + limit - 1) // limit}})
|
| 101 |
+
|
| 102 |
+
@router.get("/{talent_id}/phoneme-exam", response_model=ResponseBase)
|
| 103 |
+
async def get_phoneme_exam_progress(talent_id: int, page: int = Query(1, ge=1), limit: int = Query(10, ge=1), db: AsyncSession = Depends(get_db)):
|
| 104 |
+
service = TalentService(db)
|
| 105 |
+
result = await service.get_exam_progress(talent_id, page, limit)
|
| 106 |
+
return ResponseBase(data={"phonemeExams": result["data"], "pagination": {"currentPage": page, "totalRecords": result["total"], "totalPages": (result["total"] + limit - 1) // limit}})
|
| 107 |
+
|
| 108 |
+
@router.get("/{talent_id}/phoneme-exam/attempt/{attempt_id}/detail", response_model=ResponseBase)
|
| 109 |
+
async def get_exam_attempt_detail(talent_id: int, attempt_id: int, db: AsyncSession = Depends(get_db)):
|
| 110 |
+
service = TalentService(db)
|
| 111 |
+
data = await service.get_exam_attempt_detail(talent_id, attempt_id)
|
| 112 |
+
return ResponseBase(data={"examAttemptDetail": data})
|
| 113 |
+
|
| 114 |
+
@router.get("/{talent_id}/conversation", response_model=ResponseBase)
|
| 115 |
+
async def get_conversation_progress(talent_id: int, page: int = Query(1, ge=1), limit: int = Query(10, ge=1), db: AsyncSession = Depends(get_db)):
|
| 116 |
+
service = TalentService(db)
|
| 117 |
+
result = await service.get_conversation_progress(talent_id, page, limit)
|
| 118 |
+
return ResponseBase(data={"conversations": result["data"], "pagination": {"currentPage": page, "totalRecords": result["total"], "totalPages": (result["total"] + limit - 1) // limit}})
|
| 119 |
+
|
| 120 |
+
@router.get("/{talent_id}/interview", response_model=ResponseBase)
|
| 121 |
+
async def get_interview_progress(talent_id: int, page: int = Query(1, ge=1), limit: int = Query(10, ge=1), db: AsyncSession = Depends(get_db)):
|
| 122 |
+
service = TalentService(db)
|
| 123 |
+
result = await service.get_interview_progress(talent_id, page, limit)
|
| 124 |
+
return ResponseBase(data={"interviews": result["data"], "pagination": {"currentPage": page, "totalRecords": result["total"], "totalPages": (result["total"] + limit - 1) // limit}})
|
| 125 |
+
|
| 126 |
+
@router.get("/{talent_id}/interview/{attempt_id}/detail", response_model=ResponseBase)
|
| 127 |
+
async def get_interview_detail(talent_id: int, attempt_id: int, db: AsyncSession = Depends(get_db)):
|
| 128 |
+
service = TalentService(db)
|
| 129 |
+
data = await service.get_interview_detail(talent_id, attempt_id)
|
| 130 |
+
return ResponseBase(data={"interviewDetail": data})
|
app/api/v1/endpoints/transcribe.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, UploadFile, File
|
| 2 |
+
from app.services.audio_service import AudioService
|
| 3 |
+
from app.schemas.response import ResponseBase
|
| 4 |
+
|
| 5 |
+
router = APIRouter()
|
| 6 |
+
|
| 7 |
+
@router.post("/transcribe", response_model=ResponseBase)
|
| 8 |
+
async def transcribe_audio(file: UploadFile = File(...)):
|
| 9 |
+
"""General purpose speech-to-text using Whisper"""
|
| 10 |
+
content = await file.read()
|
| 11 |
+
text = await AudioService.transcribe_text(content)
|
| 12 |
+
return ResponseBase(data={"text": text})
|
app/core/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
from pydantic import AnyHttpUrl, field_validator
|
| 4 |
+
from pydantic_settings import BaseSettings
|
| 5 |
+
from dotenv import load_dotenv
|
| 6 |
+
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
PROJECT_NAME: str = "TalentaTalk API"
|
| 11 |
+
API_V1_STR: str = "/api/v1"
|
| 12 |
+
|
| 13 |
+
SECRET_KEY: str = os.getenv("SECRET_KEY")
|
| 14 |
+
ALGORITHM: str = "HS256"
|
| 15 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7
|
| 16 |
+
|
| 17 |
+
# DATABASE
|
| 18 |
+
DB_USER: str = os.getenv("DB_USER", "postgres")
|
| 19 |
+
DB_PASSWORD: str = os.getenv("DB_PASSWORD")
|
| 20 |
+
DB_HOST: str = os.getenv("DB_HOST", "localhost")
|
| 21 |
+
DB_PORT: str = os.getenv("DB_PORT", "5432")
|
| 22 |
+
DB_NAME: str = os.getenv("DB_NAME", "appdb")
|
| 23 |
+
|
| 24 |
+
# Konstruksi URL Database (Computed)
|
| 25 |
+
@property
|
| 26 |
+
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
| 27 |
+
return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
|
| 28 |
+
|
| 29 |
+
# AI CONFIG (Dinamis)
|
| 30 |
+
GEMINI_API_KEY: str = os.getenv("GEMINI_API_KEY")
|
| 31 |
+
GEMINI_MODEL_ID: str = os.getenv("GEMINI_MODEL_ID", "gemini-2.0-flash")
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def GEMINI_API_URL(self) -> str:
|
| 35 |
+
return f"https://generativelanguage.googleapis.com/v1beta/models/{self.GEMINI_MODEL_ID}:generateContent"
|
| 36 |
+
|
| 37 |
+
# CORS (Support Local & Production via Env)
|
| 38 |
+
BACKEND_CORS_ORIGINS: List[str] = [
|
| 39 |
+
"http://localhost:5173",
|
| 40 |
+
"http://localhost:3000",
|
| 41 |
+
"http://127.0.0.1:8000",
|
| 42 |
+
"http://127.0.0.1:3000",
|
| 43 |
+
"http://localhost:8000",
|
| 44 |
+
"https://talentatalk-admin.vercel.app",
|
| 45 |
+
"https://talentatalk-admin-git-main-cozs-projects-51dd16eb.vercel.app"
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
# PHONETIC CONSTANTS (Static Logic)
|
| 49 |
+
TIE_BAR_NORMALIZATION: Dict[str, str] = {
|
| 50 |
+
"d͡ʒ": "dʒ", "t͡ʃ": "tʃ", "t͡s": "ts", "d͡z": "dz"
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
SIMILAR_PHONEMES: Dict[str, List[str]] = {
|
| 54 |
+
"i": ["ɪ", "j"], "ɪ": ["ɛ"], "ɛ": ["æ"], "u": ["ʊ", "w"],
|
| 55 |
+
"p": ["b"], "t": ["d"], "k": ["g"], "f": ["v"],
|
| 56 |
+
"θ": ["ð"], "s": ["z"], "ʃ": ["ʒ"], "tʃ": ["dʒ"],
|
| 57 |
+
"n": ["m", "ŋ"], "l": ["r"], "ə": ["ʌ", "ɚ"],
|
| 58 |
+
"ɑ": ["ɔ", "ʌ"], "ɔ": ["oʊ"], "oʊ": ["ʊ"],
|
| 59 |
+
"eɪ": ["ɛ", "ɪ"], "aɪ": ["ɪ", "ɑ"], "ɔɪ": ["ɔ", "ɪ"],
|
| 60 |
+
"aʊ": ["ʊ", "ɑ"],
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
VOWEL_PHONEMES: List[str] = ["i", "ɪ", "ɛ", "æ", "ə", "ɚ", "ʌ", "ɑ", "ɔ", "ʊ", "u"]
|
| 64 |
+
DIPHTHONG_PHONEMES: List[str] = ["eɪ", "aɪ", "ɔɪ", "aʊ", "oʊ"]
|
| 65 |
+
CONSONANT_PHONEMES: List[str] = ["p", "b", "t", "d", "k", "g", "f", "v", "θ", "ð", "s", "z", "ʃ", "ʒ", "tʃ", "dʒ", "h", "m", "n", "ŋ", "l", "r", "j", "w"]
|
| 66 |
+
|
| 67 |
+
@field_validator("SECRET_KEY", "GEMINI_API_KEY", "DB_PASSWORD")
|
| 68 |
+
def check_empty_secrets(cls, v, info):
|
| 69 |
+
if not v:
|
| 70 |
+
raise ValueError(f"CRITICAL: {info.field_name} must be set in .env")
|
| 71 |
+
return v
|
| 72 |
+
|
| 73 |
+
class Config:
|
| 74 |
+
case_sensitive = True
|
| 75 |
+
|
| 76 |
+
settings = Settings()
|
app/core/database.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 2 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
from typing import AsyncGenerator
|
| 5 |
+
|
| 6 |
+
engine = create_async_engine(
|
| 7 |
+
settings.SQLALCHEMY_DATABASE_URI,
|
| 8 |
+
echo=False,
|
| 9 |
+
future=True,
|
| 10 |
+
pool_pre_ping=True,
|
| 11 |
+
pool_size=10,
|
| 12 |
+
max_overflow=20
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 16 |
+
bind=engine,
|
| 17 |
+
class_=AsyncSession,
|
| 18 |
+
expire_on_commit=False,
|
| 19 |
+
autocommit=False,
|
| 20 |
+
autoflush=False,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
class Base(DeclarativeBase):
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
| 27 |
+
async with AsyncSessionLocal() as session:
|
| 28 |
+
try:
|
| 29 |
+
yield session
|
| 30 |
+
finally:
|
| 31 |
+
await session.close()
|
app/core/exceptions.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import HTTPException, status
|
| 2 |
+
from typing import Any, Optional
|
| 3 |
+
|
| 4 |
+
class AppError(HTTPException):
|
| 5 |
+
def __init__(
|
| 6 |
+
self,
|
| 7 |
+
status_code: int,
|
| 8 |
+
detail: Any = None,
|
| 9 |
+
headers: Optional[dict[str, Any]] = None,
|
| 10 |
+
) -> None:
|
| 11 |
+
super().__init__(status_code=status_code, detail=detail, headers=headers)
|
| 12 |
+
|
| 13 |
+
class NotFoundError(AppError):
|
| 14 |
+
def __init__(self, resource: str = "Resource"):
|
| 15 |
+
super().__init__(
|
| 16 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 17 |
+
detail=f"{resource} not found"
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
class AuthenticationError(AppError):
|
| 21 |
+
def __init__(self, detail: str = "Could not validate credentials"):
|
| 22 |
+
super().__init__(
|
| 23 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 24 |
+
detail=detail,
|
| 25 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
class DuplicateError(AppError):
|
| 29 |
+
def __init__(self, detail: str = "Data already exists"):
|
| 30 |
+
super().__init__(
|
| 31 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 32 |
+
detail=detail
|
| 33 |
+
)
|
app/main.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from contextlib import asynccontextmanager
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.core.exceptions import AppError
|
| 8 |
+
from app.core.database import engine, Base
|
| 9 |
+
from app.api.v1.endpoints import (
|
| 10 |
+
auth, conversation, phoneme, dashboard, material,
|
| 11 |
+
talents, history, exam, transcribe, interview_flow, mobile_profile, pretest
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
@asynccontextmanager
|
| 15 |
+
async def lifespan(app: FastAPI):
|
| 16 |
+
async with engine.begin() as conn:
|
| 17 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 18 |
+
yield
|
| 19 |
+
|
| 20 |
+
app = FastAPI(title=settings.PROJECT_NAME, lifespan=lifespan)
|
| 21 |
+
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=settings.BACKEND_CORS_ORIGINS,
|
| 25 |
+
allow_credentials=True,
|
| 26 |
+
allow_methods=["*"],
|
| 27 |
+
allow_headers=["*"],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
@app.exception_handler(AppError)
|
| 31 |
+
async def app_exception_handler(request: Request, exc: AppError):
|
| 32 |
+
return JSONResponse(
|
| 33 |
+
status_code=exc.status_code,
|
| 34 |
+
content={"success": False, "message": str(exc.detail), "data": None},
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# --- WEB ADMIN ---
|
| 38 |
+
app.include_router(auth.router, prefix="/web/admin", tags=["Auth Admin"])
|
| 39 |
+
app.include_router(dashboard.router, prefix="/web/admin", tags=["Dashboard"])
|
| 40 |
+
app.include_router(material.router, prefix="/web/admin", tags=["Material"])
|
| 41 |
+
app.include_router(talents.router, prefix="/web/admin/talents", tags=["Talent Management"])
|
| 42 |
+
|
| 43 |
+
# --- MOBILE APP ---
|
| 44 |
+
app.include_router(auth.router, prefix="/api/v1/auth", tags=["Auth Mobile"])
|
| 45 |
+
app.include_router(conversation.router, prefix="/api/v1/conversation", tags=["Conversation"])
|
| 46 |
+
app.include_router(interview_flow.router, prefix="/api/v1/interview", tags=["Interview"])
|
| 47 |
+
app.include_router(phoneme.router, prefix="/api/v1/phoneme", tags=["Phoneme"])
|
| 48 |
+
app.include_router(history.router, prefix="/api/v1/history", tags=["History"])
|
| 49 |
+
app.include_router(exam.router, prefix="/api/v1/exam", tags=["Exam"])
|
| 50 |
+
app.include_router(transcribe.router, prefix="/api/v1", tags=["Transcribe"])
|
| 51 |
+
app.include_router(mobile_profile.router, prefix="/api/v1/profile", tags=["Profile Mobile"])
|
| 52 |
+
app.include_router(pretest.router, prefix="/api/v1/pretest", tags=["Pretest"])
|
| 53 |
+
|
| 54 |
+
@app.get("/")
|
| 55 |
+
def root():
|
| 56 |
+
return {"message": "TalentaTalk API v1.6 Running"}
|
app/models/models.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Text, Boolean
|
| 2 |
+
from sqlalchemy.dialects.postgresql import JSON
|
| 3 |
+
from sqlalchemy.sql import func
|
| 4 |
+
from app.core.database import Base
|
| 5 |
+
|
| 6 |
+
class Ujianfonem(Base):
|
| 7 |
+
__tablename__ = 'ujianfonem'
|
| 8 |
+
|
| 9 |
+
idujian = Column(Integer, primary_key=True, autoincrement=True)
|
| 10 |
+
idmateriujian = Column(Integer, ForeignKey('materiujian.idmateriujian'))
|
| 11 |
+
idtalent = Column(Integer, ForeignKey('talent.idtalent'))
|
| 12 |
+
kategori = Column(String(255))
|
| 13 |
+
nilai = Column(Float)
|
| 14 |
+
waktuujian = Column(DateTime)
|
| 15 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 16 |
+
|
| 17 |
+
class Detailujianfonem(Base):
|
| 18 |
+
__tablename__ = 'detailujianfonem'
|
| 19 |
+
|
| 20 |
+
iddetail = Column(Integer, primary_key=True, autoincrement=True)
|
| 21 |
+
idujian = Column(Integer, ForeignKey('ujianfonem.idujian'))
|
| 22 |
+
idsoal = Column(Integer, ForeignKey('materiujiankalimat.idmateriujiankalimat'))
|
| 23 |
+
nilai = Column(Float)
|
| 24 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 25 |
+
|
| 26 |
+
class Materiujian(Base):
|
| 27 |
+
__tablename__ = 'materiujian'
|
| 28 |
+
|
| 29 |
+
idmateriujian = Column(Integer, primary_key=True, autoincrement=True)
|
| 30 |
+
kategori = Column(String(255))
|
| 31 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 32 |
+
|
| 33 |
+
class Materiujiankalimat(Base):
|
| 34 |
+
__tablename__ = 'materiujiankalimat'
|
| 35 |
+
|
| 36 |
+
idmateriujiankalimat = Column(Integer, primary_key=True, autoincrement=True)
|
| 37 |
+
idmateriujian = Column(Integer, ForeignKey('materiujian.idmateriujian'))
|
| 38 |
+
kalimat = Column(String(255))
|
| 39 |
+
fonem = Column(String(255))
|
| 40 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 41 |
+
|
| 42 |
+
class Hasillatihanfonem(Base):
|
| 43 |
+
__tablename__ = 'hasillatihanfonem'
|
| 44 |
+
|
| 45 |
+
idhasilfonem = Column(Integer, primary_key=True, autoincrement=True)
|
| 46 |
+
typelatihan = Column(String(255))
|
| 47 |
+
idtalent = Column(Integer, ForeignKey('talent.idtalent'))
|
| 48 |
+
idsoal = Column(Integer)
|
| 49 |
+
nilai = Column(Float)
|
| 50 |
+
waktulatihan = Column(DateTime)
|
| 51 |
+
phoneme_comparison = Column(JSON)
|
| 52 |
+
|
| 53 |
+
class Hasillatihanpercakapan(Base):
|
| 54 |
+
__tablename__ = 'hasillatihanpercakapan'
|
| 55 |
+
|
| 56 |
+
idhasilpercakapan = Column(Integer, primary_key=True, autoincrement=True)
|
| 57 |
+
idtalent = Column(Integer, ForeignKey('talent.idtalent'))
|
| 58 |
+
idmateripercakapan = Column(Integer, ForeignKey('materipercakapan.idmateripercakapan'))
|
| 59 |
+
wpm = Column(Float)
|
| 60 |
+
grammar = Column(String(255))
|
| 61 |
+
waktulatihan = Column(DateTime)
|
| 62 |
+
|
| 63 |
+
class Hasillatihaninterview(Base):
|
| 64 |
+
__tablename__ = 'hasillatihaninterview'
|
| 65 |
+
|
| 66 |
+
idhasilinterview = Column(Integer, primary_key=True, autoincrement=True)
|
| 67 |
+
idtalent = Column(Integer, ForeignKey('talent.idtalent'))
|
| 68 |
+
waktulatihan = Column(DateTime)
|
| 69 |
+
feedback = Column(Text)
|
| 70 |
+
wpm = Column(Float)
|
| 71 |
+
grammar = Column(String(255))
|
| 72 |
+
|
| 73 |
+
class Manajemen(Base):
|
| 74 |
+
__tablename__ = 'manajemen'
|
| 75 |
+
|
| 76 |
+
idmanajemen = Column(Integer, primary_key=True)
|
| 77 |
+
namamanajemen = Column(String(255))
|
| 78 |
+
email = Column(String(255))
|
| 79 |
+
password = Column(String(255))
|
| 80 |
+
|
| 81 |
+
class Materifonemkalimat(Base):
|
| 82 |
+
__tablename__ = 'materifonemkalimat'
|
| 83 |
+
|
| 84 |
+
idmaterifonemkalimat = Column(Integer, primary_key=True, autoincrement=True)
|
| 85 |
+
kategori = Column(String(255))
|
| 86 |
+
kalimat = Column(String(255))
|
| 87 |
+
fonem = Column(String(255))
|
| 88 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 89 |
+
|
| 90 |
+
class Materifonemkata(Base):
|
| 91 |
+
__tablename__ = 'materifonemkata'
|
| 92 |
+
|
| 93 |
+
idmaterifonemkata = Column(Integer, primary_key=True, autoincrement=True)
|
| 94 |
+
kategori = Column(String(255))
|
| 95 |
+
kata = Column(String(255))
|
| 96 |
+
fonem = Column(String(255))
|
| 97 |
+
meaning = Column(String(255))
|
| 98 |
+
definition = Column(String(255))
|
| 99 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 100 |
+
|
| 101 |
+
class Materipercakapan(Base):
|
| 102 |
+
__tablename__ = 'materipercakapan'
|
| 103 |
+
|
| 104 |
+
idmateripercakapan = Column(Integer, primary_key=True, autoincrement=True)
|
| 105 |
+
topic = Column(String(255))
|
| 106 |
+
|
| 107 |
+
class Materiinterview(Base):
|
| 108 |
+
__tablename__ = 'materiinterview'
|
| 109 |
+
|
| 110 |
+
idmateriinterview = Column(Integer, primary_key=True, autoincrement=True)
|
| 111 |
+
question = Column(String(255))
|
| 112 |
+
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
| 113 |
+
is_active = Column(Boolean, nullable=False, server_default='true')
|
| 114 |
+
|
| 115 |
+
class Talent(Base):
|
| 116 |
+
__tablename__ = 'talent'
|
| 117 |
+
|
| 118 |
+
idtalent = Column(Integer, primary_key=True, autoincrement=True)
|
| 119 |
+
nama = Column(String(255))
|
| 120 |
+
email = Column(String(255), unique=True)
|
| 121 |
+
password = Column(String(255))
|
| 122 |
+
pretest_score = Column(Float)
|
| 123 |
+
role = Column(String(50), default='talent')
|
app/repositories/base.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generic, TypeVar, Type, Optional, List, Any
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy import select, update, delete
|
| 4 |
+
from app.core.database import Base
|
| 5 |
+
|
| 6 |
+
ModelType = TypeVar("ModelType", bound=Base)
|
| 7 |
+
|
| 8 |
+
class BaseRepository(Generic[ModelType]):
|
| 9 |
+
def __init__(self, model: Type[ModelType], db: AsyncSession):
|
| 10 |
+
self.model = model
|
| 11 |
+
self.db = db
|
| 12 |
+
|
| 13 |
+
async def get_by_id(self, id: Any) -> Optional[ModelType]:
|
| 14 |
+
query = select(self.model).where(self.model.id == id) # Asumsi primary key bernama 'id', override jika beda
|
| 15 |
+
result = await self.db.execute(query)
|
| 16 |
+
return result.scalar_one_or_none()
|
| 17 |
+
|
| 18 |
+
async def get_all(self, skip: int = 0, limit: int = 100) -> List[ModelType]:
|
| 19 |
+
query = select(self.model).offset(skip).limit(limit)
|
| 20 |
+
result = await self.db.execute(query)
|
| 21 |
+
return result.scalars().all()
|
| 22 |
+
|
| 23 |
+
async def create(self, obj_in: dict) -> ModelType:
|
| 24 |
+
db_obj = self.model(**obj_in)
|
| 25 |
+
self.db.add(db_obj)
|
| 26 |
+
await self.db.commit()
|
| 27 |
+
await self.db.refresh(db_obj)
|
| 28 |
+
return db_obj
|
| 29 |
+
|
| 30 |
+
async def update(self, db_obj: ModelType, obj_in: dict) -> ModelType:
|
| 31 |
+
for field, value in obj_in.items():
|
| 32 |
+
setattr(db_obj, field, value)
|
| 33 |
+
await self.db.commit()
|
| 34 |
+
await self.db.refresh(db_obj)
|
| 35 |
+
return db_obj
|
| 36 |
+
|
| 37 |
+
async def delete(self, db_obj: ModelType) -> None:
|
| 38 |
+
await self.db.delete(db_obj)
|
| 39 |
+
await self.db.commit()
|
app/repositories/dashboard_repository.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, func, desc, union_all, literal_column
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.models.models import (
|
| 4 |
+
Talent, Materifonemkata, Materifonemkalimat, Materiujian, Materiinterview,
|
| 5 |
+
Hasillatihanfonem, Hasillatihanpercakapan, Hasillatihaninterview, Ujianfonem, Manajemen
|
| 6 |
+
)
|
| 7 |
+
from typing import List, Dict, Any, Optional
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
|
| 10 |
+
class DashboardRepository:
|
| 11 |
+
def __init__(self, db: AsyncSession):
|
| 12 |
+
self.db = db
|
| 13 |
+
|
| 14 |
+
async def get_total_counts(self) -> Dict[str, int]:
|
| 15 |
+
total_talent = await self.db.scalar(select(func.count(Talent.idtalent)))
|
| 16 |
+
count_word = await self.db.scalar(select(func.count(Materifonemkata.idmaterifonemkata)))
|
| 17 |
+
count_sent = await self.db.scalar(select(func.count(Materifonemkalimat.idmaterifonemkalimat)))
|
| 18 |
+
total_exam = await self.db.scalar(select(func.count(func.distinct(Materiujian.kategori))))
|
| 19 |
+
total_interview = await self.db.scalar(select(func.count(Materiinterview.idmateriinterview)))
|
| 20 |
+
|
| 21 |
+
return {
|
| 22 |
+
"totalTalent": total_talent or 0,
|
| 23 |
+
"totalPronunciationMaterial": (count_word or 0) + (count_sent or 0),
|
| 24 |
+
"totalExamPhonemMaterial": total_exam or 0,
|
| 25 |
+
"totalInterviewQuestion": total_interview or 0
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async def get_recent_activities(
|
| 29 |
+
self,
|
| 30 |
+
limit: int = 10,
|
| 31 |
+
days_back: int = 30,
|
| 32 |
+
start_date: Optional[datetime] = None,
|
| 33 |
+
end_date: Optional[datetime] = None
|
| 34 |
+
) -> List[Dict[str, Any]]:
|
| 35 |
+
|
| 36 |
+
# Calculate Date Range
|
| 37 |
+
if start_date and end_date:
|
| 38 |
+
cutoff_start = start_date
|
| 39 |
+
cutoff_end = end_date
|
| 40 |
+
else:
|
| 41 |
+
cutoff_start = datetime.utcnow() - timedelta(days=days_back)
|
| 42 |
+
cutoff_end = datetime.utcnow() + timedelta(days=1) # Future buffer
|
| 43 |
+
|
| 44 |
+
# Helper to apply date filter
|
| 45 |
+
def date_filter(model_date_col):
|
| 46 |
+
return model_date_col.between(cutoff_start, cutoff_end)
|
| 47 |
+
|
| 48 |
+
q_phoneme = select(
|
| 49 |
+
Hasillatihanfonem.idtalent, Hasillatihanfonem.nilai.label("score_val"),
|
| 50 |
+
Hasillatihanfonem.waktulatihan, Hasillatihanfonem.typelatihan.label("category"),
|
| 51 |
+
literal_column("'Phoneme'").label("type")
|
| 52 |
+
).where(date_filter(Hasillatihanfonem.waktulatihan))
|
| 53 |
+
|
| 54 |
+
q_exam = select(
|
| 55 |
+
Ujianfonem.idtalent, Ujianfonem.nilai.label("score_val"),
|
| 56 |
+
Ujianfonem.waktuujian.label("waktulatihan"), Ujianfonem.kategori.label("category"),
|
| 57 |
+
literal_column("'Exam'").label("type")
|
| 58 |
+
).where(date_filter(Ujianfonem.waktuujian))
|
| 59 |
+
|
| 60 |
+
q_conv = select(
|
| 61 |
+
Hasillatihanpercakapan.idtalent, Hasillatihanpercakapan.wpm.label("score_val"),
|
| 62 |
+
Hasillatihanpercakapan.waktulatihan, literal_column("'Speaking'").label("category"),
|
| 63 |
+
literal_column("'Conversation'").label("type")
|
| 64 |
+
).where(date_filter(Hasillatihanpercakapan.waktulatihan))
|
| 65 |
+
|
| 66 |
+
q_int = select(
|
| 67 |
+
Hasillatihaninterview.idtalent, Hasillatihaninterview.wpm.label("score_val"),
|
| 68 |
+
Hasillatihaninterview.waktulatihan, literal_column("'Speaking'").label("category"),
|
| 69 |
+
literal_column("'Interview'").label("type")
|
| 70 |
+
).where(date_filter(Hasillatihaninterview.waktulatihan))
|
| 71 |
+
|
| 72 |
+
union_q = union_all(q_phoneme, q_exam, q_conv, q_int).alias("activities")
|
| 73 |
+
|
| 74 |
+
final_query = (
|
| 75 |
+
select(union_q, Talent.nama)
|
| 76 |
+
.join(Talent, Talent.idtalent == union_q.c.idtalent)
|
| 77 |
+
.order_by(desc(union_q.c.waktulatihan))
|
| 78 |
+
.limit(limit)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
result = await self.db.execute(final_query)
|
| 82 |
+
|
| 83 |
+
formatted_results = []
|
| 84 |
+
for row in result:
|
| 85 |
+
score_val = row.score_val if row.score_val is not None else 0
|
| 86 |
+
score_display = f"{score_val:.0f} WPM" if row.type in ['Conversation', 'Interview'] else f"{score_val:.0f}%"
|
| 87 |
+
|
| 88 |
+
formatted_results.append({
|
| 89 |
+
"talentId": row.idtalent,
|
| 90 |
+
"talentName": row.nama,
|
| 91 |
+
"activityType": f"{row.type} Practice" if row.type == "Phoneme" else row.type,
|
| 92 |
+
"category": row.category or "General",
|
| 93 |
+
"score": score_display,
|
| 94 |
+
"date": row.waktulatihan
|
| 95 |
+
})
|
| 96 |
+
|
| 97 |
+
return formatted_results
|
| 98 |
+
|
| 99 |
+
# --- Other methods (Specific User & Leaderboard) remain same for brevity, ensure they are in file ---
|
| 100 |
+
async def get_user_activity_dates(self, talent_id: int):
|
| 101 |
+
q1 = select(Hasillatihanfonem.waktulatihan).where(Hasillatihanfonem.idtalent == talent_id)
|
| 102 |
+
q2 = select(Ujianfonem.waktuujian).where(Ujianfonem.idtalent == talent_id)
|
| 103 |
+
q3 = select(Hasillatihanpercakapan.waktulatihan).where(Hasillatihanpercakapan.idtalent == talent_id)
|
| 104 |
+
q4 = select(Hasillatihaninterview.waktulatihan).where(Hasillatihaninterview.idtalent == talent_id)
|
| 105 |
+
union_q = union_all(q1, q2, q3, q4)
|
| 106 |
+
result = await self.db.execute(union_q)
|
| 107 |
+
return [row[0] for row in result.all()]
|
| 108 |
+
|
| 109 |
+
async def get_user_avg_pronunciation(self, talent_id: int) -> float:
|
| 110 |
+
query = select(func.avg(Hasillatihanfonem.nilai)).where(Hasillatihanfonem.idtalent == talent_id)
|
| 111 |
+
return await self.db.scalar(query) or 0.0
|
| 112 |
+
|
| 113 |
+
async def get_user_conversation_stats(self, talent_id: int):
|
| 114 |
+
query = select(func.avg(Hasillatihanpercakapan.wpm), func.count(Hasillatihanpercakapan.idhasilpercakapan)).where(Hasillatihanpercakapan.idtalent == talent_id)
|
| 115 |
+
result = await self.db.execute(query)
|
| 116 |
+
row = result.first()
|
| 117 |
+
return {"avg_wpm": row[0] or 0.0, "count": row[1] or 0}
|
| 118 |
+
|
| 119 |
+
async def get_user_interview_stats(self, talent_id: int):
|
| 120 |
+
query = select(func.avg(Hasillatihaninterview.wpm), func.count(Hasillatihaninterview.idhasilinterview)).where(Hasillatihaninterview.idtalent == talent_id)
|
| 121 |
+
result = await self.db.execute(query)
|
| 122 |
+
row = result.first()
|
| 123 |
+
return {"avg_wpm": row[0] or 0.0, "count": row[1] or 0}
|
| 124 |
+
|
| 125 |
+
async def get_user_phoneme_counts(self, talent_id: int):
|
| 126 |
+
query = select(func.count(func.distinct(Hasillatihanfonem.idsoal))).where(Hasillatihanfonem.idtalent == talent_id)
|
| 127 |
+
return await self.db.scalar(query) or 0
|
| 128 |
+
|
| 129 |
+
async def get_all_talents(self, search: str = None):
|
| 130 |
+
query = select(Talent)
|
| 131 |
+
if search: query = query.filter(Talent.nama.ilike(f"%{search}%"))
|
| 132 |
+
result = await self.db.execute(query)
|
| 133 |
+
return result.scalars().all()
|
| 134 |
+
|
| 135 |
+
async def get_all_activities_dates(self):
|
| 136 |
+
q1 = select(Hasillatihanfonem.idtalent, Hasillatihanfonem.waktulatihan)
|
| 137 |
+
q2 = select(Ujianfonem.idtalent, Ujianfonem.waktuujian.label("waktulatihan"))
|
| 138 |
+
q3 = select(Hasillatihanpercakapan.idtalent, Hasillatihanpercakapan.waktulatihan)
|
| 139 |
+
q4 = select(Hasillatihaninterview.idtalent, Hasillatihaninterview.waktulatihan)
|
| 140 |
+
union_q = union_all(q1, q2, q3, q4).alias("act")
|
| 141 |
+
query = select(union_q.c.idtalent, union_q.c.waktulatihan)
|
| 142 |
+
result = await self.db.execute(query)
|
| 143 |
+
return result.all()
|
| 144 |
+
|
| 145 |
+
async def get_highest_scoring_phoneme(self, type_latihan: str):
|
| 146 |
+
query = (select(Talent.idtalent, Talent.nama, Talent.email, func.count(func.distinct(Hasillatihanfonem.idsoal)).label('attempted'), func.avg(Hasillatihanfonem.nilai).label('avg_score')).join(Hasillatihanfonem, Talent.idtalent == Hasillatihanfonem.idtalent).where(Hasillatihanfonem.typelatihan == type_latihan).group_by(Talent.idtalent))
|
| 147 |
+
result = await self.db.execute(query)
|
| 148 |
+
return result.all()
|
| 149 |
+
|
| 150 |
+
async def get_highest_scoring_exam(self):
|
| 151 |
+
query = (select(Talent.idtalent, Talent.nama, Talent.email, func.count(func.distinct(Ujianfonem.kategori)).label('categories_attempted'), func.avg(Ujianfonem.nilai).label('avg_score'), func.count(Ujianfonem.idujian).label('total_attempts')).join(Ujianfonem, Talent.idtalent == Ujianfonem.idtalent).where(Ujianfonem.nilai != None).group_by(Talent.idtalent))
|
| 152 |
+
result = await self.db.execute(query)
|
| 153 |
+
return result.all()
|
| 154 |
+
|
| 155 |
+
async def get_highest_scoring_conversation(self):
|
| 156 |
+
query = (select(Talent.idtalent, Talent.nama, Talent.email, func.avg(Hasillatihanpercakapan.wpm).label('avg_wpm'), func.count(Hasillatihanpercakapan.idhasilpercakapan).label('total_attempts'), func.max(Hasillatihanpercakapan.waktulatihan).label('last_date')).join(Hasillatihanpercakapan, Talent.idtalent == Hasillatihanpercakapan.idtalent).group_by(Talent.idtalent))
|
| 157 |
+
result = await self.db.execute(query)
|
| 158 |
+
return result.all()
|
| 159 |
+
|
| 160 |
+
async def get_highest_scoring_interview(self):
|
| 161 |
+
query = (select(Talent.idtalent, Talent.nama, Talent.email, func.avg(Hasillatihaninterview.wpm).label('avg_wpm'), func.count(Hasillatihaninterview.idhasilinterview).label('total_attempts'), func.max(Hasillatihaninterview.waktulatihan).label('last_date')).join(Hasillatihaninterview, Talent.idtalent == Hasillatihaninterview.idtalent).group_by(Talent.idtalent))
|
| 162 |
+
result = await self.db.execute(query)
|
| 163 |
+
return result.all()
|
| 164 |
+
|
| 165 |
+
async def get_admin_by_email(self, email: str):
|
| 166 |
+
result = await self.db.execute(select(Manajemen).where(Manajemen.email == email))
|
| 167 |
+
return result.scalar_one_or_none()
|
app/repositories/exam_repository.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, and_, func
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.models.models import Ujianfonem, Detailujianfonem, Materiujian, Materiujiankalimat
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
class ExamRepository:
|
| 7 |
+
def __init__(self, db: AsyncSession):
|
| 8 |
+
self.db = db
|
| 9 |
+
|
| 10 |
+
async def get_active_exam(self, talent_id: int, exam_id: int):
|
| 11 |
+
"""Mencari ujian yang sedang berlangsung (nilai masih None)"""
|
| 12 |
+
query = select(Ujianfonem).where(
|
| 13 |
+
and_(
|
| 14 |
+
Ujianfonem.idtalent == talent_id,
|
| 15 |
+
Ujianfonem.idmateriujian == exam_id,
|
| 16 |
+
Ujianfonem.nilai == None
|
| 17 |
+
)
|
| 18 |
+
)
|
| 19 |
+
result = await self.db.execute(query)
|
| 20 |
+
return result.scalar_one_or_none()
|
| 21 |
+
|
| 22 |
+
async def get_exam_by_id(self, ujian_id: int):
|
| 23 |
+
return await self.db.get(Ujianfonem, ujian_id)
|
| 24 |
+
|
| 25 |
+
async def create_exam_entry(self, talent_id: int, exam_id: int, category: str):
|
| 26 |
+
"""Membuat sesi ujian baru"""
|
| 27 |
+
exam = Ujianfonem(
|
| 28 |
+
idmateriujian=exam_id,
|
| 29 |
+
idtalent=talent_id,
|
| 30 |
+
kategori=category,
|
| 31 |
+
nilai=None,
|
| 32 |
+
waktuujian=datetime.utcnow()
|
| 33 |
+
)
|
| 34 |
+
self.db.add(exam)
|
| 35 |
+
await self.db.commit()
|
| 36 |
+
await self.db.refresh(exam)
|
| 37 |
+
return exam
|
| 38 |
+
|
| 39 |
+
async def get_answered_question_ids(self, ujian_id: int):
|
| 40 |
+
"""Mengambil ID soal yang sudah dijawab dalam sesi ini"""
|
| 41 |
+
query = select(Detailujianfonem.idsoal).where(Detailujianfonem.idujian == ujian_id)
|
| 42 |
+
result = await self.db.execute(query)
|
| 43 |
+
return result.scalars().all()
|
| 44 |
+
|
| 45 |
+
async def get_remaining_questions(self, exam_id: int, answered_ids: list):
|
| 46 |
+
"""Mengambil soal yang belum dikerjakan"""
|
| 47 |
+
query = select(Materiujiankalimat).where(Materiujiankalimat.idmateriujian == exam_id)
|
| 48 |
+
if answered_ids:
|
| 49 |
+
query = query.where(Materiujiankalimat.idmateriujiankalimat.notin_(answered_ids))
|
| 50 |
+
|
| 51 |
+
result = await self.db.execute(query)
|
| 52 |
+
return result.scalars().all()
|
| 53 |
+
|
| 54 |
+
async def get_materi_header(self, exam_id: int):
|
| 55 |
+
return await self.db.get(Materiujian, exam_id)
|
| 56 |
+
|
| 57 |
+
async def get_materi_kalimat(self, soal_id: int):
|
| 58 |
+
return await self.db.get(Materiujiankalimat, soal_id)
|
| 59 |
+
|
| 60 |
+
async def save_detail_answer(self, ujian_id: int, soal_id: int, score: float):
|
| 61 |
+
"""Menyimpan jawaban per soal"""
|
| 62 |
+
detail = Detailujianfonem(idujian=ujian_id, idsoal=soal_id, nilai=score)
|
| 63 |
+
self.db.add(detail)
|
| 64 |
+
await self.db.commit()
|
| 65 |
+
return detail
|
| 66 |
+
|
| 67 |
+
async def get_exam_details_with_questions(self, ujian_id: int):
|
| 68 |
+
"""Mengambil detail jawaban beserta teks soalnya untuk result"""
|
| 69 |
+
query = (
|
| 70 |
+
select(Detailujianfonem, Materiujiankalimat.kalimat)
|
| 71 |
+
.join(Materiujiankalimat, Detailujianfonem.idsoal == Materiujiankalimat.idmateriujiankalimat)
|
| 72 |
+
.where(Detailujianfonem.idujian == ujian_id)
|
| 73 |
+
)
|
| 74 |
+
result = await self.db.execute(query)
|
| 75 |
+
return result.all()
|
| 76 |
+
|
| 77 |
+
async def update_final_score(self, ujian_id: int, score: float):
|
| 78 |
+
"""Update nilai akhir ujian"""
|
| 79 |
+
ujian = await self.get_exam_by_id(ujian_id)
|
| 80 |
+
if ujian:
|
| 81 |
+
ujian.nilai = score
|
| 82 |
+
await self.db.commit()
|
| 83 |
+
await self.db.refresh(ujian)
|
| 84 |
+
return ujian
|
app/repositories/history_repository.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, desc
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.models.models import (
|
| 4 |
+
Hasillatihanfonem, Hasillatihanpercakapan, Hasillatihaninterview,
|
| 5 |
+
Ujianfonem, Detailujianfonem, Materifonemkata, Materifonemkalimat,
|
| 6 |
+
Materipercakapan, Materiujiankalimat
|
| 7 |
+
)
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
|
| 10 |
+
class HistoryRepository:
|
| 11 |
+
def __init__(self, db: AsyncSession):
|
| 12 |
+
self.db = db
|
| 13 |
+
|
| 14 |
+
async def get_phoneme_history(self, talent_id: int, days_back: int = 7, skip: int = 0, limit: int = 10):
|
| 15 |
+
cutoff = datetime.utcnow() - timedelta(days=days_back)
|
| 16 |
+
|
| 17 |
+
query = (
|
| 18 |
+
select(Hasillatihanfonem)
|
| 19 |
+
.where(
|
| 20 |
+
Hasillatihanfonem.idtalent == talent_id,
|
| 21 |
+
Hasillatihanfonem.waktulatihan >= cutoff
|
| 22 |
+
)
|
| 23 |
+
.order_by(desc(Hasillatihanfonem.waktulatihan))
|
| 24 |
+
.offset(skip)
|
| 25 |
+
.limit(limit)
|
| 26 |
+
)
|
| 27 |
+
result = await self.db.execute(query)
|
| 28 |
+
items = result.scalars().all()
|
| 29 |
+
|
| 30 |
+
# Perlu manual fetch nama soal karena polimorfik (Word/Sentence)
|
| 31 |
+
enriched_items = []
|
| 32 |
+
for item in items:
|
| 33 |
+
soal_text = "Unknown"
|
| 34 |
+
if item.typelatihan == "Word":
|
| 35 |
+
q = select(Materifonemkata.kata).where(Materifonemkata.idmaterifonemkata == item.idsoal)
|
| 36 |
+
soal_text = await self.db.scalar(q)
|
| 37 |
+
elif item.typelatihan == "Sentence":
|
| 38 |
+
q = select(Materifonemkalimat.kalimat).where(Materifonemkalimat.idmaterifonemkalimat == item.idsoal)
|
| 39 |
+
soal_text = await self.db.scalar(q)
|
| 40 |
+
|
| 41 |
+
enriched_items.append({
|
| 42 |
+
"raw": item,
|
| 43 |
+
"soal_text": soal_text
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
return enriched_items
|
| 47 |
+
|
| 48 |
+
async def get_conversation_history(self, talent_id: int, days_back: int = 7):
|
| 49 |
+
cutoff = datetime.utcnow() - timedelta(days=days_back)
|
| 50 |
+
query = (
|
| 51 |
+
select(Hasillatihanpercakapan, Materipercakapan.topic)
|
| 52 |
+
.join(Materipercakapan, Materipercakapan.idmateripercakapan == Hasillatihanpercakapan.idmateripercakapan)
|
| 53 |
+
.where(
|
| 54 |
+
Hasillatihanpercakapan.idtalent == talent_id,
|
| 55 |
+
Hasillatihanpercakapan.waktulatihan >= cutoff
|
| 56 |
+
)
|
| 57 |
+
.order_by(desc(Hasillatihanpercakapan.waktulatihan))
|
| 58 |
+
)
|
| 59 |
+
result = await self.db.execute(query)
|
| 60 |
+
return result.all() # Returns tuples (Model, topic_string)
|
| 61 |
+
|
| 62 |
+
async def get_interview_history(self, talent_id: int, days_back: int = 7):
|
| 63 |
+
cutoff = datetime.utcnow() - timedelta(days=days_back)
|
| 64 |
+
query = (
|
| 65 |
+
select(Hasillatihaninterview)
|
| 66 |
+
.where(
|
| 67 |
+
Hasillatihaninterview.idtalent == talent_id,
|
| 68 |
+
Hasillatihaninterview.waktulatihan >= cutoff
|
| 69 |
+
)
|
| 70 |
+
.order_by(desc(Hasillatihaninterview.waktulatihan))
|
| 71 |
+
)
|
| 72 |
+
result = await self.db.execute(query)
|
| 73 |
+
return result.scalars().all()
|
| 74 |
+
|
| 75 |
+
async def get_exam_history(self, talent_id: int, days_back: int = 7):
|
| 76 |
+
cutoff = datetime.utcnow() - timedelta(days=days_back)
|
| 77 |
+
query = (
|
| 78 |
+
select(Ujianfonem)
|
| 79 |
+
.where(
|
| 80 |
+
Ujianfonem.idtalent == talent_id,
|
| 81 |
+
Ujianfonem.waktuujian >= cutoff
|
| 82 |
+
)
|
| 83 |
+
.order_by(desc(Ujianfonem.waktuujian))
|
| 84 |
+
)
|
| 85 |
+
result = await self.db.execute(query)
|
| 86 |
+
exams = result.scalars().all()
|
| 87 |
+
|
| 88 |
+
# Fetch details for each exam
|
| 89 |
+
history_data = []
|
| 90 |
+
for exam in exams:
|
| 91 |
+
q_detail = (
|
| 92 |
+
select(Detailujianfonem, Materiujiankalimat.kalimat)
|
| 93 |
+
.join(Materiujiankalimat, Detailujianfonem.idsoal == Materiujiankalimat.idmateriujiankalimat)
|
| 94 |
+
.where(Detailujianfonem.idujian == exam.idujian)
|
| 95 |
+
)
|
| 96 |
+
details_res = await self.db.execute(q_detail)
|
| 97 |
+
details = [{"score": r.Detailujianfonem.nilai, "kalimat": r.kalimat} for r in details_res]
|
| 98 |
+
|
| 99 |
+
history_data.append({
|
| 100 |
+
"exam": exam,
|
| 101 |
+
"details": details
|
| 102 |
+
})
|
| 103 |
+
|
| 104 |
+
return history_data
|
app/repositories/material_repository.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, func, distinct
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from sqlalchemy.exc import IntegrityError
|
| 4 |
+
from app.models.models import Materipercakapan, Materifonemkata, Materifonemkalimat, Materiujian, Materiujiankalimat, Materiinterview
|
| 5 |
+
from app.core.exceptions import DuplicateError
|
| 6 |
+
|
| 7 |
+
class MaterialRepository:
|
| 8 |
+
def __init__(self, db: AsyncSession):
|
| 9 |
+
self.db = db
|
| 10 |
+
|
| 11 |
+
# --- READ METHODS (General) ---
|
| 12 |
+
async def get_random_topic(self):
|
| 13 |
+
query = select(Materipercakapan).order_by(func.random()).limit(1)
|
| 14 |
+
result = await self.db.execute(query)
|
| 15 |
+
return result.scalar_one_or_none()
|
| 16 |
+
|
| 17 |
+
async def get_phoneme_content(self, id: int, type: str):
|
| 18 |
+
if type == "word":
|
| 19 |
+
query = select(Materifonemkata).where(Materifonemkata.idmaterifonemkata == id)
|
| 20 |
+
else:
|
| 21 |
+
query = select(Materifonemkalimat).where(Materifonemkalimat.idmaterifonemkalimat == id)
|
| 22 |
+
result = await self.db.execute(query)
|
| 23 |
+
return result.scalar_one_or_none()
|
| 24 |
+
|
| 25 |
+
# --- PRETEST & RANDOM FETCHING ---
|
| 26 |
+
async def get_random_sentences(self, limit: int = 10):
|
| 27 |
+
cat_query = select(Materifonemkalimat.kategori).distinct()
|
| 28 |
+
categories = (await self.db.execute(cat_query)).scalars().all()
|
| 29 |
+
|
| 30 |
+
results = []
|
| 31 |
+
for cat in categories:
|
| 32 |
+
if len(results) >= limit: break
|
| 33 |
+
q = select(Materifonemkalimat).where(Materifonemkalimat.kategori == cat).order_by(func.random()).limit(1)
|
| 34 |
+
item = (await self.db.execute(q)).scalar_one_or_none()
|
| 35 |
+
if item: results.append(item)
|
| 36 |
+
|
| 37 |
+
if len(results) < limit:
|
| 38 |
+
needed = limit - len(results)
|
| 39 |
+
exclude_ids = [r.idmaterifonemkalimat for r in results]
|
| 40 |
+
q_more = select(Materifonemkalimat).where(Materifonemkalimat.idmaterifonemkalimat.notin_(exclude_ids)).order_by(func.random()).limit(needed)
|
| 41 |
+
more_items = (await self.db.execute(q_more)).scalars().all()
|
| 42 |
+
results.extend(more_items)
|
| 43 |
+
|
| 44 |
+
return results
|
| 45 |
+
|
| 46 |
+
async def get_random_word_by_phoneme(self, phoneme: str):
|
| 47 |
+
query = select(Materifonemkata).where(Materifonemkata.fonem.ilike(f"%{phoneme}%")).order_by(func.random()).limit(1)
|
| 48 |
+
result = await self.db.execute(query)
|
| 49 |
+
return result.scalar_one_or_none()
|
| 50 |
+
|
| 51 |
+
async def get_random_sentence_by_phoneme(self, phoneme: str):
|
| 52 |
+
query = select(Materifonemkalimat).where(Materifonemkalimat.kategori.ilike(f"%{phoneme}%")).order_by(func.random()).limit(1)
|
| 53 |
+
result = await self.db.execute(query)
|
| 54 |
+
return result.scalar_one_or_none()
|
| 55 |
+
|
| 56 |
+
async def get_word_by_id(self, id: int):
|
| 57 |
+
return await self.db.get(Materifonemkata, id)
|
| 58 |
+
|
| 59 |
+
async def get_sentence_by_id(self, id: int):
|
| 60 |
+
return await self.db.get(Materifonemkalimat, id)
|
| 61 |
+
|
| 62 |
+
# 1. Phoneme Material (Words)
|
| 63 |
+
async def get_phoneme_materials_paginated(self, skip: int, limit: int, search: str = None):
|
| 64 |
+
# Group by category, count words, get max updated_at
|
| 65 |
+
stmt = select(
|
| 66 |
+
Materifonemkata.kategori,
|
| 67 |
+
func.count(Materifonemkata.idmaterifonemkata).label("total_words"),
|
| 68 |
+
func.max(Materifonemkata.updated_at).label("last_update")
|
| 69 |
+
).group_by(Materifonemkata.kategori)
|
| 70 |
+
|
| 71 |
+
count_stmt = select(func.count(distinct(Materifonemkata.kategori)))
|
| 72 |
+
|
| 73 |
+
if search:
|
| 74 |
+
stmt = stmt.where(Materifonemkata.kategori.ilike(f"%{search}%"))
|
| 75 |
+
count_stmt = count_stmt.where(Materifonemkata.kategori.ilike(f"%{search}%"))
|
| 76 |
+
|
| 77 |
+
total = await self.db.scalar(count_stmt) or 0
|
| 78 |
+
|
| 79 |
+
stmt = stmt.order_by(Materifonemkata.kategori).offset(skip).limit(limit)
|
| 80 |
+
result = await self.db.execute(stmt)
|
| 81 |
+
|
| 82 |
+
return result.all(), total
|
| 83 |
+
|
| 84 |
+
# 2. Exercise Phoneme (Sentences)
|
| 85 |
+
async def get_exercise_materials_paginated(self, skip: int, limit: int, search: str = None):
|
| 86 |
+
stmt = select(
|
| 87 |
+
Materifonemkalimat.kategori,
|
| 88 |
+
func.count(Materifonemkalimat.idmaterifonemkalimat).label("total_sentences"),
|
| 89 |
+
func.max(Materifonemkalimat.updated_at).label("last_update")
|
| 90 |
+
).group_by(Materifonemkalimat.kategori)
|
| 91 |
+
|
| 92 |
+
count_stmt = select(func.count(distinct(Materifonemkalimat.kategori)))
|
| 93 |
+
|
| 94 |
+
if search:
|
| 95 |
+
stmt = stmt.where(Materifonemkalimat.kategori.ilike(f"%{search}%"))
|
| 96 |
+
count_stmt = count_stmt.where(Materifonemkalimat.kategori.ilike(f"%{search}%"))
|
| 97 |
+
|
| 98 |
+
total = await self.db.scalar(count_stmt) or 0
|
| 99 |
+
|
| 100 |
+
stmt = stmt.order_by(Materifonemkalimat.kategori).offset(skip).limit(limit)
|
| 101 |
+
result = await self.db.execute(stmt)
|
| 102 |
+
|
| 103 |
+
return result.all(), total
|
| 104 |
+
|
| 105 |
+
# 3. Exam Phoneme
|
| 106 |
+
async def get_exam_materials_paginated(self, skip: int, limit: int, search: str = None):
|
| 107 |
+
stmt = select(
|
| 108 |
+
Materiujian.kategori,
|
| 109 |
+
func.count(Materiujian.idmateriujian).label("total_exams"),
|
| 110 |
+
func.max(Materiujian.updated_at).label("last_update")
|
| 111 |
+
).group_by(Materiujian.kategori)
|
| 112 |
+
|
| 113 |
+
count_stmt = select(func.count(distinct(Materiujian.kategori)))
|
| 114 |
+
|
| 115 |
+
if search:
|
| 116 |
+
stmt = stmt.where(Materiujian.kategori.ilike(f"%{search}%"))
|
| 117 |
+
count_stmt = count_stmt.where(Materiujian.kategori.ilike(f"%{search}%"))
|
| 118 |
+
|
| 119 |
+
total = await self.db.scalar(count_stmt) or 0
|
| 120 |
+
|
| 121 |
+
stmt = stmt.order_by(Materiujian.kategori).offset(skip).limit(limit)
|
| 122 |
+
result = await self.db.execute(stmt)
|
| 123 |
+
|
| 124 |
+
return result.all(), total
|
| 125 |
+
|
| 126 |
+
# --- INTERVIEW ---
|
| 127 |
+
async def get_interview_questions(self, skip: int = 0, limit: int = 10):
|
| 128 |
+
query = select(Materiinterview).order_by(Materiinterview.idmateriinterview).offset(skip).limit(limit)
|
| 129 |
+
result = await self.db.execute(query)
|
| 130 |
+
return result.scalars().all()
|
| 131 |
+
|
| 132 |
+
async def get_interview_question_by_id(self, id: int):
|
| 133 |
+
return await self.db.get(Materiinterview, id)
|
| 134 |
+
|
| 135 |
+
# --- ADMIN CRUD OPERATIONS ---
|
| 136 |
+
async def create_word(self, data: dict):
|
| 137 |
+
try:
|
| 138 |
+
obj = Materifonemkata(**data)
|
| 139 |
+
self.db.add(obj)
|
| 140 |
+
await self.db.commit()
|
| 141 |
+
await self.db.refresh(obj)
|
| 142 |
+
return obj
|
| 143 |
+
except IntegrityError:
|
| 144 |
+
await self.db.rollback()
|
| 145 |
+
raise DuplicateError("Kata tersebut sudah ada dalam kategori ini")
|
| 146 |
+
|
| 147 |
+
async def update_word(self, id: int, data: dict):
|
| 148 |
+
from sqlalchemy import update
|
| 149 |
+
stmt = update(Materifonemkata).where(Materifonemkata.idmaterifonemkata == id).values(**data)
|
| 150 |
+
await self.db.execute(stmt)
|
| 151 |
+
await self.db.commit()
|
| 152 |
+
|
| 153 |
+
async def delete_word(self, id: int):
|
| 154 |
+
from sqlalchemy import delete
|
| 155 |
+
stmt = delete(Materifonemkata).where(Materifonemkata.idmaterifonemkata == id)
|
| 156 |
+
await self.db.execute(stmt)
|
| 157 |
+
await self.db.commit()
|
| 158 |
+
|
| 159 |
+
async def create_sentence(self, data: dict):
|
| 160 |
+
try:
|
| 161 |
+
obj = Materifonemkalimat(**data)
|
| 162 |
+
self.db.add(obj)
|
| 163 |
+
await self.db.commit()
|
| 164 |
+
await self.db.refresh(obj)
|
| 165 |
+
return obj
|
| 166 |
+
except IntegrityError:
|
| 167 |
+
await self.db.rollback()
|
| 168 |
+
raise DuplicateError("Kalimat sudah ada")
|
| 169 |
+
|
| 170 |
+
async def update_sentence(self, id: int, data: dict):
|
| 171 |
+
from sqlalchemy import update
|
| 172 |
+
stmt = update(Materifonemkalimat).where(Materifonemkalimat.idmaterifonemkalimat == id).values(**data)
|
| 173 |
+
await self.db.execute(stmt)
|
| 174 |
+
await self.db.commit()
|
| 175 |
+
|
| 176 |
+
async def delete_sentence(self, id: int):
|
| 177 |
+
from sqlalchemy import delete
|
| 178 |
+
stmt = delete(Materifonemkalimat).where(Materifonemkalimat.idmaterifonemkalimat == id)
|
| 179 |
+
await self.db.execute(stmt)
|
| 180 |
+
await self.db.commit()
|
| 181 |
+
|
| 182 |
+
async def create_exam_set(self, category: str, items: list):
|
| 183 |
+
try:
|
| 184 |
+
exam_header = Materiujian(kategori=category)
|
| 185 |
+
self.db.add(exam_header)
|
| 186 |
+
await self.db.flush()
|
| 187 |
+
for item in items:
|
| 188 |
+
detail = Materiujiankalimat(
|
| 189 |
+
idmateriujian=exam_header.idmateriujian,
|
| 190 |
+
kalimat=item["sentence"],
|
| 191 |
+
fonem=item["phoneme"]
|
| 192 |
+
)
|
| 193 |
+
self.db.add(detail)
|
| 194 |
+
await self.db.commit()
|
| 195 |
+
return exam_header
|
| 196 |
+
except Exception as e:
|
| 197 |
+
await self.db.rollback()
|
| 198 |
+
raise e
|
| 199 |
+
|
| 200 |
+
async def get_exam_header(self, id: int):
|
| 201 |
+
return await self.db.get(Materiujian, id)
|
| 202 |
+
|
| 203 |
+
async def delete_exam(self, id: int):
|
| 204 |
+
from sqlalchemy import delete
|
| 205 |
+
await self.db.execute(delete(Materiujiankalimat).where(Materiujiankalimat.idmateriujian == id))
|
| 206 |
+
await self.db.execute(delete(Materiujian).where(Materiujian.idmateriujian == id))
|
| 207 |
+
await self.db.commit()
|
| 208 |
+
|
| 209 |
+
async def update_exam_sentences(self, exam_id: int, items: list):
|
| 210 |
+
from sqlalchemy import update
|
| 211 |
+
for item in items:
|
| 212 |
+
stmt = update(Materiujiankalimat).where(Materiujiankalimat.idmateriujiankalimat == item["id_sentence"]).values(kalimat=item["sentence"], fonem=item["phoneme"])
|
| 213 |
+
await self.db.execute(stmt)
|
| 214 |
+
await self.db.commit()
|
| 215 |
+
|
| 216 |
+
async def create_interview_question(self, question: str):
|
| 217 |
+
obj = Materiinterview(question=question, is_active=True)
|
| 218 |
+
self.db.add(obj)
|
| 219 |
+
await self.db.commit()
|
| 220 |
+
await self.db.refresh(obj)
|
| 221 |
+
return obj
|
| 222 |
+
|
| 223 |
+
async def update_interview_question(self, id: int, question: str):
|
| 224 |
+
obj = await self.get_interview_question_by_id(id)
|
| 225 |
+
if obj:
|
| 226 |
+
obj.question = question
|
| 227 |
+
await self.db.commit()
|
| 228 |
+
await self.db.refresh(obj)
|
| 229 |
+
return obj
|
| 230 |
+
|
| 231 |
+
async def delete_interview_question(self, id: int):
|
| 232 |
+
from sqlalchemy import delete
|
| 233 |
+
await self.db.execute(delete(Materiinterview).where(Materiinterview.idmateriinterview == id))
|
| 234 |
+
await self.db.commit()
|
| 235 |
+
|
| 236 |
+
async def toggle_interview_status(self, id: int):
|
| 237 |
+
obj = await self.get_interview_question_by_id(id)
|
| 238 |
+
if obj:
|
| 239 |
+
obj.is_active = not obj.is_active
|
| 240 |
+
await self.db.commit()
|
| 241 |
+
await self.db.refresh(obj)
|
| 242 |
+
return obj
|
| 243 |
+
|
| 244 |
+
async def swap_interview_order(self, id: int, direction: str):
|
| 245 |
+
current = await self.get_interview_question_by_id(id)
|
| 246 |
+
if not current: return False
|
| 247 |
+
|
| 248 |
+
target_q = None
|
| 249 |
+
if direction == "up":
|
| 250 |
+
q = select(Materiinterview).where(Materiinterview.idmateriinterview < id).order_by(Materiinterview.idmateriinterview.desc()).limit(1)
|
| 251 |
+
else:
|
| 252 |
+
q = select(Materiinterview).where(Materiinterview.idmateriinterview > id).order_by(Materiinterview.idmateriinterview.asc()).limit(1)
|
| 253 |
+
|
| 254 |
+
target = (await self.db.execute(q)).scalar_one_or_none()
|
| 255 |
+
|
| 256 |
+
if target:
|
| 257 |
+
temp_q, temp_s = current.question, current.is_active
|
| 258 |
+
current.question, current.is_active = target.question, target.is_active
|
| 259 |
+
target.question, target.is_active = temp_q, temp_s
|
| 260 |
+
await self.db.commit()
|
| 261 |
+
return True
|
| 262 |
+
return False
|
| 263 |
+
|
| 264 |
+
async def get_all_word_categories(self):
|
| 265 |
+
result = await self.db.execute(select(Materifonemkata.kategori).distinct())
|
| 266 |
+
return result.scalars().all()
|
| 267 |
+
|
| 268 |
+
async def get_all_sentence_categories(self):
|
| 269 |
+
result = await self.db.execute(select(Materifonemkalimat.kategori).distinct())
|
| 270 |
+
return result.scalars().all()
|
| 271 |
+
|
| 272 |
+
async def get_words_by_category(self, category: str):
|
| 273 |
+
result = await self.db.execute(select(Materifonemkata).where(Materifonemkata.kategori == category))
|
| 274 |
+
return result.scalars().all()
|
| 275 |
+
|
| 276 |
+
async def get_sentences_by_category(self, category: str):
|
| 277 |
+
result = await self.db.execute(select(Materifonemkalimat).where(Materifonemkalimat.kategori == category))
|
| 278 |
+
return result.scalars().all()
|
app/repositories/score_repository.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.models.models import Hasillatihanfonem, Hasillatihanpercakapan, Hasillatihaninterview
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
class ScoreRepository:
|
| 6 |
+
def __init__(self, db: AsyncSession):
|
| 7 |
+
self.db = db
|
| 8 |
+
|
| 9 |
+
async def save_phoneme_result(self, talent_id: int, soal_id: int, type: str, score: float, comparison: dict):
|
| 10 |
+
# Comparison adalah DICT/JSON, bukan list
|
| 11 |
+
result = Hasillatihanfonem(
|
| 12 |
+
idtalent=talent_id,
|
| 13 |
+
idsoal=soal_id,
|
| 14 |
+
typelatihan=type,
|
| 15 |
+
nilai=score,
|
| 16 |
+
phoneme_comparison=comparison,
|
| 17 |
+
waktulatihan=datetime.now()
|
| 18 |
+
)
|
| 19 |
+
self.db.add(result)
|
| 20 |
+
await self.db.commit()
|
| 21 |
+
await self.db.refresh(result)
|
| 22 |
+
return result
|
| 23 |
+
|
| 24 |
+
async def save_chat_result(self, talent_id: int, topic_id: int, wpm: float, grammar: str):
|
| 25 |
+
result = Hasillatihanpercakapan(
|
| 26 |
+
idtalent=talent_id,
|
| 27 |
+
idmateripercakapan=topic_id,
|
| 28 |
+
wpm=wpm,
|
| 29 |
+
grammar=grammar,
|
| 30 |
+
waktulatihan=datetime.now()
|
| 31 |
+
)
|
| 32 |
+
self.db.add(result)
|
| 33 |
+
await self.db.commit()
|
| 34 |
+
await self.db.refresh(result)
|
| 35 |
+
return result
|
| 36 |
+
|
| 37 |
+
# METHOD UNTUK INTERVIEW
|
| 38 |
+
async def save_interview_result(self, talent_id: int, wpm: float, grammar: str, feedback: str):
|
| 39 |
+
result = Hasillatihaninterview(
|
| 40 |
+
idtalent=talent_id,
|
| 41 |
+
wpm=wpm,
|
| 42 |
+
grammar=grammar,
|
| 43 |
+
feedback=feedback,
|
| 44 |
+
waktulatihan=datetime.now()
|
| 45 |
+
)
|
| 46 |
+
self.db.add(result)
|
| 47 |
+
await self.db.commit()
|
| 48 |
+
await self.db.refresh(result)
|
| 49 |
+
return result
|
app/repositories/talent_repository.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import select, func, or_, desc, and_
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
from app.repositories.base import BaseRepository
|
| 4 |
+
from app.models.models import (
|
| 5 |
+
Talent, Ujianfonem, Hasillatihanfonem, Materifonemkata,
|
| 6 |
+
Materifonemkalimat, Hasillatihanpercakapan, Materipercakapan,
|
| 7 |
+
Hasillatihaninterview, Detailujianfonem, Materiujiankalimat
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
class TalentRepository(BaseRepository[Talent]):
|
| 11 |
+
def __init__(self, db: AsyncSession):
|
| 12 |
+
super().__init__(Talent, db)
|
| 13 |
+
|
| 14 |
+
async def get_by_email(self, email: str):
|
| 15 |
+
query = select(Talent).where(Talent.email == email)
|
| 16 |
+
result = await self.db.execute(query)
|
| 17 |
+
return result.scalar_one_or_none()
|
| 18 |
+
|
| 19 |
+
async def get_talents_paginated(self, skip: int = 0, limit: int = 10, search: str = None):
|
| 20 |
+
query = select(Talent)
|
| 21 |
+
if search:
|
| 22 |
+
search_filter = or_(Talent.nama.ilike(f"%{search}%"), Talent.email.ilike(f"%{search}%"))
|
| 23 |
+
query = query.filter(search_filter)
|
| 24 |
+
count_query = select(func.count()).select_from(query.subquery())
|
| 25 |
+
total = await self.db.scalar(count_query)
|
| 26 |
+
query = query.order_by(Talent.idtalent.asc()).offset(skip).limit(limit)
|
| 27 |
+
result = await self.db.execute(query)
|
| 28 |
+
return result.scalars().all(), total
|
| 29 |
+
|
| 30 |
+
async def get_talent_progress_stats(self, talent_id: int):
|
| 31 |
+
q_exam = select(Ujianfonem.nilai).where(Ujianfonem.idtalent == talent_id).order_by(desc(Ujianfonem.waktuujian)).limit(1)
|
| 32 |
+
latest_exam = await self.db.scalar(q_exam)
|
| 33 |
+
q_high = select(func.max(Ujianfonem.nilai)).where(Ujianfonem.idtalent == talent_id)
|
| 34 |
+
highest_exam = await self.db.scalar(q_high)
|
| 35 |
+
total_word = await self.db.scalar(select(func.count(Materifonemkata.idmaterifonemkata)))
|
| 36 |
+
total_sent = await self.db.scalar(select(func.count(Materifonemkalimat.idmaterifonemkalimat)))
|
| 37 |
+
total_materi = (total_word or 0) + (total_sent or 0)
|
| 38 |
+
q_done = select(func.count(func.distinct(Hasillatihanfonem.idsoal))).where(Hasillatihanfonem.idtalent == talent_id)
|
| 39 |
+
done = await self.db.scalar(q_done) or 0
|
| 40 |
+
progress_percent = (done / total_materi * 100) if total_materi > 0 else 0
|
| 41 |
+
return {"latest_exam": latest_exam, "highest_exam": highest_exam, "progress": progress_percent}
|
| 42 |
+
|
| 43 |
+
async def get_phoneme_history_paginated(self, talent_id: int, type_latihan: str, skip: int, limit: int):
|
| 44 |
+
base_query = select(Hasillatihanfonem).where(and_(Hasillatihanfonem.idtalent == talent_id, Hasillatihanfonem.typelatihan == type_latihan))
|
| 45 |
+
count_query = select(func.count()).select_from(base_query.subquery())
|
| 46 |
+
total = await self.db.scalar(count_query) or 0
|
| 47 |
+
query = base_query.order_by(desc(Hasillatihanfonem.waktulatihan)).offset(skip).limit(limit)
|
| 48 |
+
result = await self.db.execute(query)
|
| 49 |
+
items = result.scalars().all()
|
| 50 |
+
enriched = []
|
| 51 |
+
for item in items:
|
| 52 |
+
text_val = "Unknown"
|
| 53 |
+
category = "General"
|
| 54 |
+
if type_latihan == "Word":
|
| 55 |
+
text_val = await self.db.scalar(select(Materifonemkata.kata).where(Materifonemkata.idmaterifonemkata == item.idsoal))
|
| 56 |
+
category = await self.db.scalar(select(Materifonemkata.kategori).where(Materifonemkata.idmaterifonemkata == item.idsoal))
|
| 57 |
+
else:
|
| 58 |
+
text_val = await self.db.scalar(select(Materifonemkalimat.kalimat).where(Materifonemkalimat.idmaterifonemkalimat == item.idsoal))
|
| 59 |
+
category = await self.db.scalar(select(Materifonemkalimat.kategori).where(Materifonemkalimat.idmaterifonemkalimat == item.idsoal))
|
| 60 |
+
enriched.append({"category": category, "content": text_val, "score": item.nilai, "date": item.waktulatihan})
|
| 61 |
+
return enriched, total
|
| 62 |
+
|
| 63 |
+
async def get_exam_history_paginated(self, talent_id: int, skip: int, limit: int):
|
| 64 |
+
base_query = select(Ujianfonem).where(Ujianfonem.idtalent == talent_id)
|
| 65 |
+
count_query = select(func.count()).select_from(base_query.subquery())
|
| 66 |
+
total = await self.db.scalar(count_query) or 0
|
| 67 |
+
query = base_query.order_by(desc(Ujianfonem.waktuujian)).offset(skip).limit(limit)
|
| 68 |
+
result = await self.db.execute(query)
|
| 69 |
+
return result.scalars().all(), total
|
| 70 |
+
|
| 71 |
+
async def get_exam_attempt_detail(self, talent_id: int, attempt_id: int):
|
| 72 |
+
exam = await self.db.get(Ujianfonem, attempt_id)
|
| 73 |
+
if not exam or exam.idtalent != talent_id: return None, None
|
| 74 |
+
q_details = select(Detailujianfonem, Materiujiankalimat).join(Materiujiankalimat, Detailujianfonem.idsoal == Materiujiankalimat.idmateriujiankalimat).where(Detailujianfonem.idujian == attempt_id)
|
| 75 |
+
result = await self.db.execute(q_details)
|
| 76 |
+
return exam, result.all()
|
| 77 |
+
|
| 78 |
+
async def get_conversation_history_paginated(self, talent_id: int, skip: int, limit: int):
|
| 79 |
+
base_query = select(Hasillatihanpercakapan, Materipercakapan.topic).join(Materipercakapan, Hasillatihanpercakapan.idmateripercakapan == Materipercakapan.idmateripercakapan).where(Hasillatihanpercakapan.idtalent == talent_id)
|
| 80 |
+
count_query = select(func.count()).select_from(base_query.subquery())
|
| 81 |
+
total = await self.db.scalar(count_query) or 0
|
| 82 |
+
query = base_query.order_by(desc(Hasillatihanpercakapan.waktulatihan)).offset(skip).limit(limit)
|
| 83 |
+
result = await self.db.execute(query)
|
| 84 |
+
return result.all(), total
|
| 85 |
+
|
| 86 |
+
async def get_interview_history_paginated(self, talent_id: int, skip: int, limit: int):
|
| 87 |
+
base_query = select(Hasillatihaninterview).where(Hasillatihaninterview.idtalent == talent_id)
|
| 88 |
+
count_query = select(func.count()).select_from(base_query.subquery())
|
| 89 |
+
total = await self.db.scalar(count_query) or 0
|
| 90 |
+
query = base_query.order_by(desc(Hasillatihaninterview.waktulatihan)).offset(skip).limit(limit)
|
| 91 |
+
result = await self.db.execute(query)
|
| 92 |
+
return result.scalars().all(), total
|
| 93 |
+
|
| 94 |
+
async def get_interview_detail(self, talent_id: int, attempt_id: int):
|
| 95 |
+
query = select(Hasillatihaninterview).where(and_(Hasillatihaninterview.idhasilinterview == attempt_id, Hasillatihaninterview.idtalent == talent_id))
|
| 96 |
+
result = await self.db.execute(query)
|
| 97 |
+
return result.scalar_one_or_none()
|
app/schemas/auth.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
|
| 3 |
+
class LoginRequest(BaseModel):
|
| 4 |
+
email: EmailStr
|
| 5 |
+
password: str
|
| 6 |
+
|
| 7 |
+
class TalentCreate(BaseModel):
|
| 8 |
+
nama: str
|
| 9 |
+
email: EmailStr
|
| 10 |
+
password: str
|
| 11 |
+
role: str = "talent"
|
| 12 |
+
|
| 13 |
+
class Token(BaseModel):
|
| 14 |
+
access_token: str
|
| 15 |
+
token_type: str
|
| 16 |
+
role: str
|
| 17 |
+
user_id: int
|
app/schemas/conversation.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
class Topic(BaseModel):
|
| 5 |
+
id: int
|
| 6 |
+
title: str
|
| 7 |
+
description: str
|
| 8 |
+
|
| 9 |
+
class TopicListResponse(BaseModel):
|
| 10 |
+
topics: List[Topic]
|
| 11 |
+
|
| 12 |
+
class ChatInput(BaseModel):
|
| 13 |
+
user_input: str
|
| 14 |
+
duration: str
|
| 15 |
+
topic_id: Optional[int] = 1
|
| 16 |
+
|
| 17 |
+
class ChatResponse(BaseModel):
|
| 18 |
+
response: str
|
| 19 |
+
confidence_score: int
|
| 20 |
+
grammar_check: str
|
| 21 |
+
|
| 22 |
+
class ConversationStart(BaseModel):
|
| 23 |
+
topic: str
|
| 24 |
+
initial_question: str
|
| 25 |
+
|
| 26 |
+
class ChatMessage(BaseModel):
|
| 27 |
+
role: str
|
| 28 |
+
content: str
|
| 29 |
+
|
| 30 |
+
class ConversationHistory(BaseModel):
|
| 31 |
+
session_id: str
|
| 32 |
+
topic: str
|
| 33 |
+
history: List[ChatMessage]
|
app/schemas/dashboard.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
# --- Stats & Activity ---
|
| 5 |
+
class DashboardStats(BaseModel):
|
| 6 |
+
totalTalent: int
|
| 7 |
+
totalPronunciationMaterial: int
|
| 8 |
+
totalExamPhonemMaterial: int
|
| 9 |
+
totalInterviewQuestion: int
|
| 10 |
+
|
| 11 |
+
class RecentActivityItem(BaseModel):
|
| 12 |
+
talentId: int
|
| 13 |
+
talentName: str
|
| 14 |
+
activityType: str
|
| 15 |
+
category: Optional[str] = "General"
|
| 16 |
+
score: str
|
| 17 |
+
date: str
|
| 18 |
+
|
| 19 |
+
# --- Leaderboards ---
|
| 20 |
+
class LearnerItem(BaseModel):
|
| 21 |
+
no: int
|
| 22 |
+
id: int
|
| 23 |
+
talentName: str
|
| 24 |
+
email: str
|
| 25 |
+
# Untuk Top Active
|
| 26 |
+
highestStreak: Optional[str] = None
|
| 27 |
+
currentStreak: Optional[str] = None
|
| 28 |
+
# Untuk Highest Scoring
|
| 29 |
+
overallCompletion: Optional[str] = None
|
| 30 |
+
overallPercentage: Optional[str] = None
|
| 31 |
+
completionRate: Optional[str] = None
|
| 32 |
+
# Untuk Conversation/Interview
|
| 33 |
+
wpm: Optional[str] = None
|
| 34 |
+
grammer: Optional[str] = None
|
| 35 |
+
feedback: Optional[str] = None
|
| 36 |
+
date: Optional[str] = None
|
| 37 |
+
totalAttempts: Optional[int] = None
|
| 38 |
+
|
| 39 |
+
class PaginationInfo(BaseModel):
|
| 40 |
+
currentPage: int
|
| 41 |
+
totalPages: int
|
| 42 |
+
totalRecords: int
|
| 43 |
+
showing: str
|
| 44 |
+
|
| 45 |
+
class PaginatedListResponse(BaseModel):
|
| 46 |
+
learners: List[LearnerItem]
|
| 47 |
+
pagination: PaginationInfo
|
| 48 |
+
|
| 49 |
+
# --- Admin Profile ---
|
| 50 |
+
class AdminProfile(BaseModel):
|
| 51 |
+
idUser: str
|
| 52 |
+
name: str
|
| 53 |
+
email: str
|
| 54 |
+
role: str
|
| 55 |
+
createdAt: Optional[str] = None
|
| 56 |
+
lastLogin: Optional[str] = None
|
| 57 |
+
|
| 58 |
+
class AdminUpdate(BaseModel):
|
| 59 |
+
nama: str
|
| 60 |
+
email: EmailStr
|
| 61 |
+
|
| 62 |
+
class AdminPasswordUpdate(BaseModel):
|
| 63 |
+
new_password: str
|
| 64 |
+
|
| 65 |
+
class DashboardResponse(BaseModel):
|
| 66 |
+
statistics: DashboardStats
|
| 67 |
+
recentActivities: List[RecentActivityItem]
|
app/schemas/exam.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional, Any
|
| 3 |
+
|
| 4 |
+
class ExamStartResponse(BaseModel):
|
| 5 |
+
id_ujianfonem: int
|
| 6 |
+
remaining: int
|
| 7 |
+
data: List[dict]
|
| 8 |
+
|
| 9 |
+
class ExamResultDetail(BaseModel):
|
| 10 |
+
idsoal: int
|
| 11 |
+
kalimat: str
|
| 12 |
+
nilai: float
|
| 13 |
+
|
| 14 |
+
class ExamResultResponse(BaseModel):
|
| 15 |
+
success: bool
|
| 16 |
+
id_ujian: int
|
| 17 |
+
kategori: str
|
| 18 |
+
jumlah_soal: int
|
| 19 |
+
nilai_rata_rata: float
|
| 20 |
+
detail: List[ExamResultDetail]
|
app/schemas/material.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
|
| 4 |
+
# --- Shared ---
|
| 5 |
+
class SwapOrderRequest(BaseModel):
|
| 6 |
+
direction: str = Field(..., pattern="^(up|down)$")
|
| 7 |
+
|
| 8 |
+
# --- Word Material ---
|
| 9 |
+
class PhonemeWordCreate(BaseModel):
|
| 10 |
+
phoneme_category: str
|
| 11 |
+
word: str
|
| 12 |
+
meaning: str
|
| 13 |
+
word_definition: Optional[str] = ""
|
| 14 |
+
phoneme: str
|
| 15 |
+
|
| 16 |
+
class PhonemeWordUpdate(BaseModel):
|
| 17 |
+
word: Optional[str] = None
|
| 18 |
+
meaning: Optional[str] = None
|
| 19 |
+
word_definition: Optional[str] = None
|
| 20 |
+
phoneme: Optional[str] = None
|
| 21 |
+
|
| 22 |
+
# --- Sentence Exercise ---
|
| 23 |
+
class PhonemeSentenceCreate(BaseModel):
|
| 24 |
+
phoneme_category: str
|
| 25 |
+
sentence: str = Field(..., min_length=10)
|
| 26 |
+
phoneme: str
|
| 27 |
+
|
| 28 |
+
class PhonemeSentenceUpdate(BaseModel):
|
| 29 |
+
sentence: Optional[str] = None
|
| 30 |
+
phoneme: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
# --- Exam ---
|
| 33 |
+
class ExamSentenceItem(BaseModel):
|
| 34 |
+
id_sentence: int
|
| 35 |
+
sentence: str
|
| 36 |
+
phoneme: str
|
| 37 |
+
|
| 38 |
+
class ExamCreate(BaseModel):
|
| 39 |
+
category: str
|
| 40 |
+
items: List[ExamSentenceItem]
|
| 41 |
+
|
| 42 |
+
class ExamUpdate(BaseModel):
|
| 43 |
+
sentences: List[ExamSentenceItem]
|
| 44 |
+
|
| 45 |
+
# --- Interview ---
|
| 46 |
+
class InterviewQuestionCreate(BaseModel):
|
| 47 |
+
interview_question: str = Field(..., min_length=5)
|
| 48 |
+
|
| 49 |
+
class InterviewQuestionUpdate(BaseModel):
|
| 50 |
+
interview_question: str = Field(..., min_length=5)
|
| 51 |
+
|
| 52 |
+
class InterviewQuestionResponse(BaseModel):
|
| 53 |
+
questionId: str
|
| 54 |
+
interviewQuestion: str
|
| 55 |
+
isActive: bool
|
| 56 |
+
createdAt: str
|
| 57 |
+
orderPosition: int
|
app/schemas/phoneme.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Optional, Dict, Any
|
| 3 |
+
|
| 4 |
+
class PhonemeComparisonItem(BaseModel):
|
| 5 |
+
target: str
|
| 6 |
+
user: str
|
| 7 |
+
status: str
|
| 8 |
+
similarity: int
|
| 9 |
+
|
| 10 |
+
class PhonemeCheckResponse(BaseModel):
|
| 11 |
+
similarity_percent: str
|
| 12 |
+
accuracy_score: float
|
| 13 |
+
target_phonemes: str
|
| 14 |
+
user_phonemes: str
|
| 15 |
+
phoneme_comparison: List[PhonemeComparisonItem]
|
| 16 |
+
gemini_analysis: Optional[Dict[str, Any]] = None
|
app/schemas/response.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Generic, TypeVar, Optional, Any
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
T = TypeVar("T")
|
| 5 |
+
|
| 6 |
+
class ResponseBase(BaseModel, Generic[T]):
|
| 7 |
+
success: bool = True
|
| 8 |
+
message: str = "Success"
|
| 9 |
+
data: Optional[T] = None
|
app/schemas/talent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, EmailStr, Field, validator
|
| 2 |
+
from typing import Optional, List
|
| 3 |
+
|
| 4 |
+
# --- READ SCHEMAS (OUTPUT) ---
|
| 5 |
+
|
| 6 |
+
class TalentDTO(BaseModel):
|
| 7 |
+
id: int
|
| 8 |
+
talentName: str = Field(alias="nama")
|
| 9 |
+
email: EmailStr
|
| 10 |
+
role: str
|
| 11 |
+
pretest: str
|
| 12 |
+
highestExam: str
|
| 13 |
+
progress: str
|
| 14 |
+
|
| 15 |
+
class Config:
|
| 16 |
+
from_attributes = True
|
| 17 |
+
populate_by_name = True
|
| 18 |
+
|
| 19 |
+
class TalentDetailDTO(TalentDTO):
|
| 20 |
+
"""
|
| 21 |
+
Schema lengkap untuk detail page talent.
|
| 22 |
+
"""
|
| 23 |
+
total_phoneme_completed: int
|
| 24 |
+
total_conversation_completed: int
|
| 25 |
+
total_interview_completed: int
|
| 26 |
+
last_activity_date: Optional[str] = None
|
| 27 |
+
average_pronunciation_score: float = 0.0
|
| 28 |
+
average_speaking_wpm: float = 0.0
|
| 29 |
+
|
| 30 |
+
# --- WRITE SCHEMAS (INPUT) ---
|
| 31 |
+
|
| 32 |
+
class TalentUpdate(BaseModel):
|
| 33 |
+
"""
|
| 34 |
+
Schema untuk update data profile talent (Nama, Email, Role).
|
| 35 |
+
Semua field optional agar bisa update parsial.
|
| 36 |
+
"""
|
| 37 |
+
nama: Optional[str] = None
|
| 38 |
+
email: Optional[EmailStr] = None
|
| 39 |
+
role: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
@validator('nama')
|
| 42 |
+
def validate_nama(cls, v):
|
| 43 |
+
if v is not None and not v.strip():
|
| 44 |
+
raise ValueError('Nama tidak boleh kosong')
|
| 45 |
+
return v.strip() if v else v
|
| 46 |
+
|
| 47 |
+
class TalentPasswordUpdate(BaseModel):
|
| 48 |
+
"""
|
| 49 |
+
Schema khusus untuk ganti password talent.
|
| 50 |
+
"""
|
| 51 |
+
new_password: str = Field(..., min_length=6, description="Password minimal 6 karakter")
|
app/seeder.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
from passlib.context import CryptContext
|
| 4 |
+
from sqlalchemy import delete
|
| 5 |
+
from app.core.database import AsyncSessionLocal, engine, Base
|
| 6 |
+
from app.models.models import Manajemen
|
| 7 |
+
|
| 8 |
+
# Logging
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
# Password hashing
|
| 13 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 14 |
+
|
| 15 |
+
def get_password_hash(password: str) -> str:
|
| 16 |
+
return pwd_context.hash(password)
|
| 17 |
+
|
| 18 |
+
# Data Admin
|
| 19 |
+
ADMIN_DATA = [
|
| 20 |
+
{
|
| 21 |
+
"namamanajemen": "Admin",
|
| 22 |
+
"email": "admin@gmail.com",
|
| 23 |
+
"password": "admin123"
|
| 24 |
+
}
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
async def init_tables():
|
| 28 |
+
logger.info("🛠️ Memeriksa dan membuat tabel database...")
|
| 29 |
+
async with engine.begin() as conn:
|
| 30 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 31 |
+
logger.info("✅ Tabel siap.")
|
| 32 |
+
|
| 33 |
+
async def seed_admins():
|
| 34 |
+
await init_tables()
|
| 35 |
+
logger.info("🌱 Memulai clean seeding data Admin...")
|
| 36 |
+
|
| 37 |
+
async with AsyncSessionLocal() as session:
|
| 38 |
+
try:
|
| 39 |
+
# 1. HAPUS SEMUA DATA LAMA
|
| 40 |
+
logger.warning("🧹 Menghapus seluruh data Manajemen...")
|
| 41 |
+
await session.execute(delete(Manajemen))
|
| 42 |
+
await session.commit()
|
| 43 |
+
|
| 44 |
+
# 2. INSERT DATA BARU
|
| 45 |
+
for data in ADMIN_DATA:
|
| 46 |
+
admin = Manajemen(
|
| 47 |
+
namamanajemen=data["namamanajemen"],
|
| 48 |
+
email=data["email"],
|
| 49 |
+
password=get_password_hash(data["password"])
|
| 50 |
+
)
|
| 51 |
+
session.add(admin)
|
| 52 |
+
|
| 53 |
+
await session.commit()
|
| 54 |
+
logger.info("🎉 Clean seeding Admin berhasil!")
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
await session.rollback()
|
| 58 |
+
logger.error(f"❌ Seeder gagal: {e}")
|
| 59 |
+
finally:
|
| 60 |
+
await session.close()
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
asyncio.run(seed_admins())
|
app/services/audio_service.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import logging
|
| 3 |
+
import numpy as np
|
| 4 |
+
import librosa
|
| 5 |
+
import torch
|
| 6 |
+
import whisper
|
| 7 |
+
import os
|
| 8 |
+
import tempfile
|
| 9 |
+
from transformers import AutoProcessor, AutoModelForCTC
|
| 10 |
+
from starlette.concurrency import run_in_threadpool
|
| 11 |
+
from app.core.exceptions import AppError
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class AudioService:
|
| 16 |
+
# --- PHONEME MODELS (Wav2Vec2) ---
|
| 17 |
+
_phoneme_processor = None
|
| 18 |
+
_phoneme_model = None
|
| 19 |
+
_sampling_rate = 16000
|
| 20 |
+
|
| 21 |
+
# --- TEXT MODEL (Whisper) ---
|
| 22 |
+
_whisper_model = None
|
| 23 |
+
|
| 24 |
+
@classmethod
|
| 25 |
+
def _load_phoneme_model(cls):
|
| 26 |
+
if cls._phoneme_processor is None or cls._phoneme_model is None:
|
| 27 |
+
try:
|
| 28 |
+
print("Loading Wav2Vec2 Model...")
|
| 29 |
+
cls._phoneme_processor = AutoProcessor.from_pretrained("bookbot/wav2vec2-ljspeech-gruut")
|
| 30 |
+
cls._phoneme_model = AutoModelForCTC.from_pretrained("bookbot/wav2vec2-ljspeech-gruut")
|
| 31 |
+
except Exception as e:
|
| 32 |
+
logger.error(f"Failed to load Phoneme model: {e}")
|
| 33 |
+
raise AppError(status_code=500, detail="Phoneme processing unavailable")
|
| 34 |
+
|
| 35 |
+
@classmethod
|
| 36 |
+
def _load_whisper_model(cls):
|
| 37 |
+
if cls._whisper_model is None:
|
| 38 |
+
try:
|
| 39 |
+
print("Loading Whisper Model...")
|
| 40 |
+
cls._whisper_model = whisper.load_model("small") # Bisa ganti 'base' atau 'tiny' agar lebih cepat
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Failed to load Whisper model: {e}")
|
| 43 |
+
raise AppError(status_code=500, detail="Text transcription unavailable")
|
| 44 |
+
|
| 45 |
+
@classmethod
|
| 46 |
+
def _process_phoneme_sync(cls, audio_bytes: bytes) -> str:
|
| 47 |
+
cls._load_phoneme_model()
|
| 48 |
+
try:
|
| 49 |
+
audio, _ = librosa.load(io.BytesIO(audio_bytes), sr=cls._sampling_rate)
|
| 50 |
+
audio = audio / np.max(np.abs(audio)) # Normalize
|
| 51 |
+
|
| 52 |
+
inputs = cls._phoneme_processor(
|
| 53 |
+
audio,
|
| 54 |
+
return_tensors="pt",
|
| 55 |
+
sampling_rate=cls._sampling_rate,
|
| 56 |
+
padding=True
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
with torch.no_grad():
|
| 60 |
+
logits = cls._phoneme_model(inputs.input_values).logits
|
| 61 |
+
|
| 62 |
+
predicted_ids = torch.argmax(logits, dim=-1)
|
| 63 |
+
transcription = cls._phoneme_processor.batch_decode(predicted_ids)[0]
|
| 64 |
+
|
| 65 |
+
return transcription.strip()
|
| 66 |
+
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Phoneme processing error: {e}")
|
| 69 |
+
raise AppError(status_code=400, detail="Failed to process audio file")
|
| 70 |
+
|
| 71 |
+
@classmethod
|
| 72 |
+
def _process_whisper_sync(cls, audio_bytes: bytes) -> str:
|
| 73 |
+
cls._load_whisper_model()
|
| 74 |
+
try:
|
| 75 |
+
# Whisper butuh file path, jadi kita buat temp file
|
| 76 |
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as temp_audio:
|
| 77 |
+
temp_audio.write(audio_bytes)
|
| 78 |
+
temp_path = temp_audio.name
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
result = cls._whisper_model.transcribe(temp_path, language="en")
|
| 82 |
+
return result["text"].strip()
|
| 83 |
+
finally:
|
| 84 |
+
# Cleanup temp file
|
| 85 |
+
if os.path.exists(temp_path):
|
| 86 |
+
os.remove(temp_path)
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Whisper processing error: {e}")
|
| 90 |
+
raise AppError(status_code=400, detail="Failed to transcribe text")
|
| 91 |
+
|
| 92 |
+
@classmethod
|
| 93 |
+
async def transcribe(cls, audio_bytes: bytes) -> str:
|
| 94 |
+
"""Transcribe to Phonemes (Async wrapper)"""
|
| 95 |
+
return await run_in_threadpool(cls._process_phoneme_sync, audio_bytes)
|
| 96 |
+
|
| 97 |
+
@classmethod
|
| 98 |
+
async def transcribe_text(cls, audio_bytes: bytes) -> str:
|
| 99 |
+
"""Transcribe to English Text (Async wrapper for Whisper)"""
|
| 100 |
+
return await run_in_threadpool(cls._process_whisper_sync, audio_bytes)
|
app/services/auth_service.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timedelta
|
| 2 |
+
from typing import Any, Union
|
| 3 |
+
from jose import jwt
|
| 4 |
+
from passlib.context import CryptContext
|
| 5 |
+
from app.core.config import settings
|
| 6 |
+
from app.core.exceptions import AuthenticationError, DuplicateError
|
| 7 |
+
from app.repositories.talent_repository import TalentRepository
|
| 8 |
+
from app.schemas.auth import LoginRequest, TalentCreate
|
| 9 |
+
from app.models.models import Manajemen
|
| 10 |
+
from sqlalchemy import select
|
| 11 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 12 |
+
|
| 13 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 14 |
+
|
| 15 |
+
class AuthService:
|
| 16 |
+
def __init__(self, talent_repo: TalentRepository):
|
| 17 |
+
self.talent_repo = talent_repo
|
| 18 |
+
|
| 19 |
+
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
| 20 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 21 |
+
|
| 22 |
+
def get_password_hash(self, password: str) -> str:
|
| 23 |
+
return pwd_context.hash(password)
|
| 24 |
+
|
| 25 |
+
def create_access_token(self, subject: Union[str, Any], role: str, extra_data: dict = None) -> str:
|
| 26 |
+
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 27 |
+
to_encode = {
|
| 28 |
+
"exp": expire,
|
| 29 |
+
"sub": str(subject),
|
| 30 |
+
"role": role,
|
| 31 |
+
"id": subject
|
| 32 |
+
}
|
| 33 |
+
if extra_data:
|
| 34 |
+
to_encode.update(extra_data)
|
| 35 |
+
|
| 36 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
| 37 |
+
return encoded_jwt
|
| 38 |
+
|
| 39 |
+
async def authenticate_talent(self, login_data: LoginRequest) -> dict:
|
| 40 |
+
talent = await self.talent_repo.get_by_email(login_data.email)
|
| 41 |
+
|
| 42 |
+
if not talent:
|
| 43 |
+
raise AuthenticationError("Email tidak terdaftar")
|
| 44 |
+
|
| 45 |
+
if not self.verify_password(login_data.password, talent.password):
|
| 46 |
+
raise AuthenticationError("Password salah")
|
| 47 |
+
|
| 48 |
+
token = self.create_access_token(talent.idtalent, role="talent")
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
"access_token": token,
|
| 52 |
+
"token_type": "bearer",
|
| 53 |
+
"role": "talent",
|
| 54 |
+
"user_id": str(talent.idtalent)
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async def register_talent(self, data: TalentCreate):
|
| 58 |
+
existing = await self.talent_repo.get_by_email(data.email)
|
| 59 |
+
if existing:
|
| 60 |
+
raise DuplicateError("Email sudah terdaftar")
|
| 61 |
+
|
| 62 |
+
hashed_pw = self.get_password_hash(data.password)
|
| 63 |
+
|
| 64 |
+
new_talent_data = {
|
| 65 |
+
"nama": data.nama,
|
| 66 |
+
"email": data.email,
|
| 67 |
+
"password": hashed_pw,
|
| 68 |
+
"role": data.role
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return await self.talent_repo.create(new_talent_data)
|
| 72 |
+
|
| 73 |
+
async def authenticate_admin(self, login_data: LoginRequest, db_session: AsyncSession) -> dict:
|
| 74 |
+
"""Autentikasi khusus untuk Manajemen/Admin"""
|
| 75 |
+
query = select(Manajemen).where(Manajemen.email == login_data.email)
|
| 76 |
+
result = await db_session.execute(query)
|
| 77 |
+
admin = result.scalar_one_or_none()
|
| 78 |
+
|
| 79 |
+
if not admin:
|
| 80 |
+
raise AuthenticationError("Admin email tidak terdaftar")
|
| 81 |
+
|
| 82 |
+
if not self.verify_password(login_data.password, admin.password):
|
| 83 |
+
raise AuthenticationError("Password admin salah")
|
| 84 |
+
|
| 85 |
+
token = self.create_access_token(admin.idmanajemen, role="manajemen")
|
| 86 |
+
|
| 87 |
+
return {
|
| 88 |
+
"access_token": token,
|
| 89 |
+
"token_type": "bearer",
|
| 90 |
+
"idUser": f"ADM{admin.idmanajemen:03d}",
|
| 91 |
+
"name": admin.namamanajemen,
|
| 92 |
+
"role": "admin",
|
| 93 |
+
"email": admin.email
|
| 94 |
+
}
|
app/services/conversation_service.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.material_repository import MaterialRepository
|
| 3 |
+
from app.repositories.score_repository import ScoreRepository
|
| 4 |
+
from app.services.llm_service import LLMService
|
| 5 |
+
from app.core.exceptions import AppError
|
| 6 |
+
import json
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
class ConversationService:
|
| 10 |
+
def __init__(self, db: AsyncSession):
|
| 11 |
+
self.material_repo = MaterialRepository(db)
|
| 12 |
+
self.score_repo = ScoreRepository(db)
|
| 13 |
+
|
| 14 |
+
# Hardcoded Topics
|
| 15 |
+
HARDCODED_TOPICS = [
|
| 16 |
+
{"id": 1, "title": "Technology Trends", "description": "Discussing AI, Blockchain, and future tech."},
|
| 17 |
+
{"id": 2, "title": "Job Interview", "description": "General job interview preparation."},
|
| 18 |
+
{"id": 3, "title": "Daily Routine", "description": "Talk about hobbies, work, and daily life."},
|
| 19 |
+
{"id": 4, "title": "Travel & Culture", "description": "Discussing vacation spots and cultural differences."},
|
| 20 |
+
{"id": 5, "title": "Business Meeting", "description": "Simulate a formal business meeting environment."},
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
async def get_topics(self):
|
| 24 |
+
return self.HARDCODED_TOPICS
|
| 25 |
+
|
| 26 |
+
async def start_session(self):
|
| 27 |
+
return {"topic": "Select a topic", "message": "Please select a topic to begin."}
|
| 28 |
+
|
| 29 |
+
async def process_chat(self, user_input: str, duration: str, talent_id: int, topic_id: int = 1):
|
| 30 |
+
selected_topic = next((t for t in self.HARDCODED_TOPICS if t["id"] == topic_id), self.HARDCODED_TOPICS[0])
|
| 31 |
+
topic_name = selected_topic["title"]
|
| 32 |
+
|
| 33 |
+
from app.utils.calculation_utils import CalculationHelper
|
| 34 |
+
wpm = CalculationHelper.calculate_wpm(user_input, duration)
|
| 35 |
+
confidence = min(100, max(0, int(wpm)))
|
| 36 |
+
|
| 37 |
+
prompt = f"""
|
| 38 |
+
Context: Professional Conversation about '{topic_name}'.
|
| 39 |
+
User said: "{user_input}"
|
| 40 |
+
Task: Check grammar & Respond naturally relevant to the topic.
|
| 41 |
+
Return JSON: {{ "grammar_check": "...", "response": "..." }}
|
| 42 |
+
"""
|
| 43 |
+
ai_raw = await LLMService.generate(prompt)
|
| 44 |
+
|
| 45 |
+
# Parse AI
|
| 46 |
+
response_text = ai_raw
|
| 47 |
+
grammar_text = "Analysis included"
|
| 48 |
+
try:
|
| 49 |
+
match = re.search(r'\{.*\}', ai_raw, re.DOTALL)
|
| 50 |
+
if match:
|
| 51 |
+
js = json.loads(match.group())
|
| 52 |
+
response_text = js.get("response", ai_raw)
|
| 53 |
+
grammar_text = js.get("grammar_check", "")
|
| 54 |
+
except:
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
await self.score_repo.save_chat_result(
|
| 58 |
+
talent_id=talent_id,
|
| 59 |
+
topic_id=1,
|
| 60 |
+
wpm=wpm,
|
| 61 |
+
grammar=grammar_text[:255]
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
return {
|
| 65 |
+
"response": response_text,
|
| 66 |
+
"confidence_score": confidence,
|
| 67 |
+
"grammar_check": grammar_text
|
| 68 |
+
}
|
app/services/dashboard_service.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.dashboard_repository import DashboardRepository
|
| 3 |
+
from app.repositories.base import BaseRepository
|
| 4 |
+
from app.models.models import Manajemen
|
| 5 |
+
from app.utils.time_utils import TimeUtils
|
| 6 |
+
from app.core.exceptions import NotFoundError
|
| 7 |
+
from passlib.context import CryptContext
|
| 8 |
+
from typing import List, Dict, Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 12 |
+
|
| 13 |
+
class DashboardService:
|
| 14 |
+
def __init__(self, db: AsyncSession):
|
| 15 |
+
self.repo = DashboardRepository(db)
|
| 16 |
+
self.db = db
|
| 17 |
+
|
| 18 |
+
async def get_admin_dashboard(
|
| 19 |
+
self,
|
| 20 |
+
limit: int = 10,
|
| 21 |
+
days_back: int = 30,
|
| 22 |
+
start_date: Optional[datetime] = None,
|
| 23 |
+
end_date: Optional[datetime] = None
|
| 24 |
+
):
|
| 25 |
+
stats = await self.repo.get_total_counts()
|
| 26 |
+
activities = await self.repo.get_recent_activities(
|
| 27 |
+
limit=limit,
|
| 28 |
+
days_back=days_back,
|
| 29 |
+
start_date=start_date,
|
| 30 |
+
end_date=end_date
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
formatted_activities = []
|
| 34 |
+
for act in activities:
|
| 35 |
+
new_act = dict(act)
|
| 36 |
+
new_act["date"] = TimeUtils.format_to_wib(act["date"])
|
| 37 |
+
formatted_activities.append(new_act)
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
"statistics": stats,
|
| 41 |
+
"recentActivities": formatted_activities,
|
| 42 |
+
"filter": {
|
| 43 |
+
"limit": limit,
|
| 44 |
+
"daysBack": days_back,
|
| 45 |
+
"dateRange": "Custom" if start_date else f"Last {days_back} days"
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async def get_leaderboard(self, category: str, page: int, limit: int, search: str):
|
| 50 |
+
data = []
|
| 51 |
+
total_records = 0
|
| 52 |
+
|
| 53 |
+
if category == "topActive":
|
| 54 |
+
all_talents = await self.repo.get_all_talents(search)
|
| 55 |
+
all_activities = await self.repo.get_all_activities_dates()
|
| 56 |
+
talent_dates: Dict[int, List[datetime]] = {t.idtalent: [] for t in all_talents}
|
| 57 |
+
for row in all_activities:
|
| 58 |
+
if row.idtalent in talent_dates:
|
| 59 |
+
talent_dates[row.idtalent].append(row.waktulatihan)
|
| 60 |
+
|
| 61 |
+
processed_talents = []
|
| 62 |
+
for talent in all_talents:
|
| 63 |
+
dates = talent_dates[talent.idtalent]
|
| 64 |
+
highest, current = TimeUtils.calculate_streaks(dates)
|
| 65 |
+
processed_talents.append({
|
| 66 |
+
"id": talent.idtalent,
|
| 67 |
+
"talentName": talent.nama,
|
| 68 |
+
"email": talent.email,
|
| 69 |
+
"highestStreak": f"{highest} Days",
|
| 70 |
+
"currentStreak": f"{current} Days",
|
| 71 |
+
"_sort_current": current,
|
| 72 |
+
"_sort_highest": highest
|
| 73 |
+
})
|
| 74 |
+
|
| 75 |
+
processed_talents.sort(key=lambda x: (-x["_sort_current"], -x["_sort_highest"], x["talentName"]))
|
| 76 |
+
total_records = len(processed_talents)
|
| 77 |
+
start_idx = (page - 1) * limit
|
| 78 |
+
paged_data = processed_talents[start_idx:start_idx+limit]
|
| 79 |
+
|
| 80 |
+
for i, item in enumerate(paged_data):
|
| 81 |
+
clean_item = {k: v for k, v in item.items() if not k.startswith('_')}
|
| 82 |
+
clean_item["no"] = start_idx + i + 1
|
| 83 |
+
data.append(clean_item)
|
| 84 |
+
|
| 85 |
+
elif category in ["phoneme_material_exercise", "phoneme_exercise"]:
|
| 86 |
+
type_latihan = "Word" if category == "phoneme_material_exercise" else "Sentence"
|
| 87 |
+
results = await self.repo.get_highest_scoring_phoneme(type_latihan)
|
| 88 |
+
if search: results = [r for r in results if search.lower() in r.nama.lower()]
|
| 89 |
+
total_records = len(results)
|
| 90 |
+
sorted_res = sorted(results, key=lambda x: (-x.avg_score, -x.attempted))
|
| 91 |
+
start_idx = (page - 1) * limit
|
| 92 |
+
paged = sorted_res[start_idx:start_idx+limit]
|
| 93 |
+
for i, row in enumerate(paged):
|
| 94 |
+
data.append({
|
| 95 |
+
"no": start_idx + i + 1, "id": row.idtalent, "talentName": row.nama, "email": row.email,
|
| 96 |
+
"overallCompletion": f"{row.attempted} attempted", "overallPercentage": f"{row.avg_score:.0f}%", "completionRate": "N/A"
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
elif category == "phoneme_exam":
|
| 100 |
+
results = await self.repo.get_highest_scoring_exam()
|
| 101 |
+
if search: results = [r for r in results if search.lower() in r.nama.lower()]
|
| 102 |
+
total_records = len(results)
|
| 103 |
+
sorted_res = sorted(results, key=lambda x: (-x.avg_score, -x.categories_attempted))
|
| 104 |
+
start_idx = (page - 1) * limit
|
| 105 |
+
paged = sorted_res[start_idx:start_idx+limit]
|
| 106 |
+
for i, row in enumerate(paged):
|
| 107 |
+
data.append({
|
| 108 |
+
"no": start_idx + i + 1, "id": row.idtalent, "talentName": row.nama, "email": row.email,
|
| 109 |
+
"overallCompletion": f"{row.categories_attempted} categories", "overallPercentage": f"{row.avg_score:.0f}%", "totalAttempts": row.total_attempts
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
elif category in ["conversation", "interview"]:
|
| 113 |
+
is_interview = category == "interview"
|
| 114 |
+
results = await (self.repo.get_highest_scoring_interview() if is_interview else self.repo.get_highest_scoring_conversation())
|
| 115 |
+
if search: results = [r for r in results if search.lower() in r.nama.lower()]
|
| 116 |
+
total_records = len(results)
|
| 117 |
+
sorted_res = sorted(results, key=lambda x: (-x.avg_wpm, -x.total_attempts))
|
| 118 |
+
start_idx = (page - 1) * limit
|
| 119 |
+
paged = sorted_res[start_idx:start_idx+limit]
|
| 120 |
+
for i, row in enumerate(paged):
|
| 121 |
+
data.append({
|
| 122 |
+
"no": start_idx + i + 1, "id": row.idtalent, "talentName": row.nama, "email": row.email,
|
| 123 |
+
"wpm": f"{row.avg_wpm:.0f} WPM", "totalAttempts": row.total_attempts, "date": TimeUtils.format_to_wib(row.last_date)
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
total_pages = (total_records + limit - 1) // limit if limit > 0 else 0
|
| 127 |
+
showing_start = ((page - 1) * limit) + 1 if total_records > 0 else 0
|
| 128 |
+
showing_end = min(page * limit, total_records)
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"learners": data,
|
| 132 |
+
"pagination": {
|
| 133 |
+
"currentPage": page, "totalPages": total_pages, "totalRecords": total_records,
|
| 134 |
+
"showing": f"Showing {showing_start} to {showing_end} of {total_records} entries"
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async def update_admin(self, admin_id: int, nama: str, email: str):
|
| 139 |
+
repo_base = BaseRepository(Manajemen, self.db)
|
| 140 |
+
admin = await repo_base.get_by_id(admin_id)
|
| 141 |
+
if not admin: raise NotFoundError("Admin")
|
| 142 |
+
existing = await self.repo.get_admin_by_email(email)
|
| 143 |
+
if existing and existing.idmanajemen != admin_id: raise ValueError("Email already used")
|
| 144 |
+
return await repo_base.update(admin, {"namamanajemen": nama, "email": email})
|
| 145 |
+
|
| 146 |
+
async def change_admin_password(self, admin_id: int, new_password: str):
|
| 147 |
+
repo_base = BaseRepository(Manajemen, self.db)
|
| 148 |
+
admin = await repo_base.get_by_id(admin_id)
|
| 149 |
+
if not admin: raise NotFoundError("Admin")
|
| 150 |
+
hashed = pwd_context.hash(new_password)
|
| 151 |
+
return await repo_base.update(admin, {"password": hashed})
|
app/services/exam_service.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.exam_repository import ExamRepository
|
| 3 |
+
from app.services.audio_service import AudioService
|
| 4 |
+
from app.utils.phoneme_utils import PhonemeMatcher
|
| 5 |
+
from app.core.exceptions import NotFoundError, AppError
|
| 6 |
+
|
| 7 |
+
class ExamService:
|
| 8 |
+
def __init__(self, db: AsyncSession):
|
| 9 |
+
self.repo = ExamRepository(db)
|
| 10 |
+
|
| 11 |
+
async def start_exam_session(self, talent_id: int, material_exam_id: int):
|
| 12 |
+
# 1. Cek materi ada tidak
|
| 13 |
+
materi = await self.repo.get_materi_header(material_exam_id)
|
| 14 |
+
if not materi:
|
| 15 |
+
raise NotFoundError("Materi Ujian")
|
| 16 |
+
|
| 17 |
+
# 2. Cek apakah ada sesi ujian yang belum selesai (nilai is None)
|
| 18 |
+
ujian = await self.repo.get_active_exam(talent_id, material_exam_id)
|
| 19 |
+
|
| 20 |
+
# Jika tidak ada sesi aktif, buat baru
|
| 21 |
+
if not ujian:
|
| 22 |
+
ujian = await self.repo.create_exam_entry(talent_id, material_exam_id, materi.kategori)
|
| 23 |
+
|
| 24 |
+
# 3. Cek soal mana saja yang sudah dikerjakan
|
| 25 |
+
answered_ids = await self.repo.get_answered_question_ids(ujian.idujian)
|
| 26 |
+
|
| 27 |
+
# 4. Jika sudah 10 soal terjawab, berarti ujian selesai
|
| 28 |
+
# (Dalam kasus edge case dimana status belum terupdate)
|
| 29 |
+
if len(answered_ids) >= 10:
|
| 30 |
+
# Trigger finish calculation just in case
|
| 31 |
+
await self.finish_exam(ujian.idujian)
|
| 32 |
+
return {"message": "Ujian telah selesai", "id_ujianfonem": ujian.idujian, "data": []}
|
| 33 |
+
|
| 34 |
+
# 5. Ambil sisa soal yang belum dikerjakan
|
| 35 |
+
remaining_q = await self.repo.get_remaining_questions(material_exam_id, answered_ids)
|
| 36 |
+
|
| 37 |
+
# Format data untuk mobile
|
| 38 |
+
data = [
|
| 39 |
+
{
|
| 40 |
+
"id": q.idmateriujiankalimat,
|
| 41 |
+
"kalimat": q.kalimat,
|
| 42 |
+
"fonem": q.fonem,
|
| 43 |
+
"kategori": materi.kategori # Tambahan info kategori
|
| 44 |
+
}
|
| 45 |
+
for q in remaining_q
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
return {
|
| 49 |
+
"id_ujianfonem": ujian.idujian,
|
| 50 |
+
"remaining": len(remaining_q),
|
| 51 |
+
"data": data
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async def process_answer(self, ujian_id: int, soal_id: int, audio_bytes: bytes):
|
| 55 |
+
# 1. Ambil kalimat target
|
| 56 |
+
soal = await self.repo.get_materi_kalimat(soal_id)
|
| 57 |
+
if not soal: raise NotFoundError("Soal")
|
| 58 |
+
|
| 59 |
+
# 2. Transkripsi & Scoring (Heavy Task)
|
| 60 |
+
user_phonemes = await AudioService.transcribe(audio_bytes)
|
| 61 |
+
alignment = PhonemeMatcher.align_phonemes(soal.fonem, user_phonemes)
|
| 62 |
+
score = PhonemeMatcher.calculate_accuracy(alignment)
|
| 63 |
+
|
| 64 |
+
# 3. Simpan Detail Jawaban
|
| 65 |
+
await self.repo.save_detail_answer(ujian_id, soal_id, score)
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"similarity_percent": f"{score}%",
|
| 69 |
+
"phoneme_comparison": alignment,
|
| 70 |
+
"user_phonemes": user_phonemes,
|
| 71 |
+
"target_phonemes": soal.fonem
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
async def finish_exam(self, ujian_id: int):
|
| 75 |
+
"""Menghitung nilai akhir dan menutup sesi ujian"""
|
| 76 |
+
details_with_q = await self.repo.get_exam_details_with_questions(ujian_id)
|
| 77 |
+
|
| 78 |
+
if not details_with_q:
|
| 79 |
+
raise AppError(status_code=400, detail="Belum ada soal yang dikerjakan")
|
| 80 |
+
|
| 81 |
+
# Hitung rata-rata
|
| 82 |
+
total_score = sum(row.Detailujianfonem.nilai for row in details_with_q)
|
| 83 |
+
count = len(details_with_q)
|
| 84 |
+
avg_score = round(total_score / count, 2) if count > 0 else 0
|
| 85 |
+
|
| 86 |
+
# Update header ujian
|
| 87 |
+
ujian = await self.repo.update_final_score(ujian_id, avg_score)
|
| 88 |
+
|
| 89 |
+
# Format detail untuk response result screen
|
| 90 |
+
detail_list = [
|
| 91 |
+
{
|
| 92 |
+
"idsoal": row.Detailujianfonem.idsoal,
|
| 93 |
+
"kalimat": row.kalimat,
|
| 94 |
+
"nilai": row.Detailujianfonem.nilai
|
| 95 |
+
}
|
| 96 |
+
for row in details_with_q
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
"success": True,
|
| 101 |
+
"id_ujian": ujian.idujian,
|
| 102 |
+
"kategori": ujian.kategori,
|
| 103 |
+
"jumlah_soal": count,
|
| 104 |
+
"nilai_rata_rata": avg_score,
|
| 105 |
+
"detail": detail_list
|
| 106 |
+
}
|
app/services/interview_service.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.material_repository import MaterialRepository
|
| 3 |
+
from app.repositories.score_repository import ScoreRepository
|
| 4 |
+
from app.services.llm_service import LLMService
|
| 5 |
+
from app.utils.calculation_utils import CalculationHelper
|
| 6 |
+
from app.core.exceptions import AppError, NotFoundError
|
| 7 |
+
import json
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
|
| 11 |
+
class InterviewService:
|
| 12 |
+
_sessions: Dict[str, Any] = {}
|
| 13 |
+
|
| 14 |
+
def __init__(self, db: AsyncSession):
|
| 15 |
+
self.material_repo = MaterialRepository(db)
|
| 16 |
+
self.score_repo = ScoreRepository(db)
|
| 17 |
+
|
| 18 |
+
async def start_session(self, session_id: str):
|
| 19 |
+
if not session_id: session_id = str(uuid.uuid4())
|
| 20 |
+
questions = await self.material_repo.get_interview_questions(0, 100)
|
| 21 |
+
if not questions: raise NotFoundError("Interview Questions")
|
| 22 |
+
self._sessions[session_id] = {"questions": [q.question for q in questions], "current_index": 0, "step": "main", "history": [], "answers": [], "completed": False, "off_topic_count": 0}
|
| 23 |
+
return {"session_id": session_id, "question": questions[0].question, "step": "main"}
|
| 24 |
+
|
| 25 |
+
async def get_session_status(self, session_id: str):
|
| 26 |
+
session = self._sessions.get(session_id)
|
| 27 |
+
if not session: return {"status": "not_found", "session_exists": False}
|
| 28 |
+
return {"success": True, "status": {"session_id": session_id, "interview_started": True, "interview_completed": session["completed"], "current_question_index": session["current_index"], "total_questions": len(session["questions"]), "current_step": session["step"], "session_exists": True}}
|
| 29 |
+
|
| 30 |
+
async def process_answer(self, session_id: str, answer: str, duration: str):
|
| 31 |
+
session = self._sessions.get(session_id)
|
| 32 |
+
if not session: raise AppError(status_code=400, detail="Session expired. Please restart.")
|
| 33 |
+
if session["completed"]: return {"status": "completed", "message": "Interview already finished."}
|
| 34 |
+
current_q_text = session["questions"][session["current_index"]]
|
| 35 |
+
|
| 36 |
+
if session["step"] == "main":
|
| 37 |
+
relevance = await LLMService.check_relevance(current_q_text, answer)
|
| 38 |
+
if not relevance.get("is_relevant", True):
|
| 39 |
+
session["off_topic_count"] += 1
|
| 40 |
+
return {"status": "off_topic", "message": f"Your answer seems unrelated. {relevance.get('reason', 'Please focus on the question.')} Let's try again: {current_q_text}", "interview_completed": False}
|
| 41 |
+
|
| 42 |
+
wpm = CalculationHelper.calculate_wpm(answer, duration)
|
| 43 |
+
session["answers"].append({"question": current_q_text, "answer": answer, "wpm": wpm})
|
| 44 |
+
session["history"].append({"role": "user", "content": answer})
|
| 45 |
+
|
| 46 |
+
if session["step"] == "main":
|
| 47 |
+
ai_resp = await LLMService.generate_interview_followup(current_q_text, answer)
|
| 48 |
+
session["step"] = "followup"
|
| 49 |
+
followup_q = ai_resp.get("followup_question", "Could you explain more?")
|
| 50 |
+
session["history"].append({"role": "interviewer", "content": followup_q})
|
| 51 |
+
return {"status": "continue", "feedback": ai_resp.get("feedback", "Good."), "message": followup_q, "interview_completed": False}
|
| 52 |
+
|
| 53 |
+
elif session["step"] == "followup":
|
| 54 |
+
session["current_index"] += 1
|
| 55 |
+
session["step"] = "main"
|
| 56 |
+
if session["current_index"] >= len(session["questions"]):
|
| 57 |
+
session["completed"] = True
|
| 58 |
+
return {"status": "completed", "feedback": "Excellent. That concludes our interview.", "message": "Interview completed.", "interview_completed": True}
|
| 59 |
+
next_q = session["questions"][session["current_index"]]
|
| 60 |
+
session["history"].append({"role": "interviewer", "content": next_q})
|
| 61 |
+
return {"status": "continue", "feedback": "Thank you.", "message": next_q, "interview_completed": False}
|
| 62 |
+
|
| 63 |
+
async def generate_summary(self, session_id: str, talent_id: int):
|
| 64 |
+
session = self._sessions.get(session_id)
|
| 65 |
+
if not session: raise AppError(status_code=400, detail="Session not found")
|
| 66 |
+
ai_summary = await LLMService.generate_interview_feedback(session["history"])
|
| 67 |
+
answers = session["answers"]
|
| 68 |
+
total_wpm = sum(a["wpm"] for a in answers)
|
| 69 |
+
avg_wpm = total_wpm / len(answers) if answers else 0
|
| 70 |
+
grammar_score = ai_summary.get("summary", {}).get("overall_performance", {}).get("grammar_usage", "Fair")
|
| 71 |
+
saved_record = await self.score_repo.save_interview_result(talent_id=talent_id, wpm=avg_wpm, grammar=grammar_score, feedback=json.dumps(ai_summary))
|
| 72 |
+
if session_id in self._sessions: del self._sessions[session_id]
|
| 73 |
+
return {"success": True, "summary": ai_summary.get("summary"), "statistics": {"average_wpm": round(avg_wpm, 2), "total_answers": len(answers)}, "id": saved_record.idhasilinterview}
|
app/services/llm_service.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import aiohttp
|
| 2 |
+
import logging
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from typing import Dict, Any, List
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.core.exceptions import AppError
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class LLMService:
|
| 12 |
+
HEADERS = {"Content-Type": "application/json"}
|
| 13 |
+
|
| 14 |
+
@staticmethod
|
| 15 |
+
def _clean_json_string(text: str) -> str:
|
| 16 |
+
text = re.sub(r'```json\s*', '', text)
|
| 17 |
+
text = re.sub(r'```\s*', '', text)
|
| 18 |
+
return text.strip()
|
| 19 |
+
|
| 20 |
+
@staticmethod
|
| 21 |
+
async def _send_request(payload: dict) -> str:
|
| 22 |
+
url = f"{settings.GEMINI_API_URL}?key={settings.GEMINI_API_KEY}"
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
async with aiohttp.ClientSession() as session:
|
| 26 |
+
async with session.post(
|
| 27 |
+
url,
|
| 28 |
+
json=payload,
|
| 29 |
+
headers=LLMService.HEADERS,
|
| 30 |
+
timeout=30
|
| 31 |
+
) as response:
|
| 32 |
+
if response.status != 200:
|
| 33 |
+
error_text = await response.text()
|
| 34 |
+
logger.error(f"Gemini API Error ({response.status}): {error_text}")
|
| 35 |
+
if response.status == 429:
|
| 36 |
+
raise AppError(status_code=429, detail="AI Traffic Limit Exceeded")
|
| 37 |
+
raise AppError(status_code=502, detail="AI Service Provider Error")
|
| 38 |
+
|
| 39 |
+
data = await response.json()
|
| 40 |
+
try:
|
| 41 |
+
return data["candidates"][0]["content"]["parts"][0]["text"]
|
| 42 |
+
except (KeyError, IndexError):
|
| 43 |
+
logger.error(f"Unexpected API Response format: {data}")
|
| 44 |
+
return "{}"
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"LLM Network Error: {e}")
|
| 47 |
+
if isinstance(e, AppError):
|
| 48 |
+
raise e
|
| 49 |
+
return "{}"
|
| 50 |
+
|
| 51 |
+
@staticmethod
|
| 52 |
+
async def generate(prompt_text: str) -> str:
|
| 53 |
+
return await LLMService._send_request({
|
| 54 |
+
"contents": [{"parts": [{"text": prompt_text}]}]
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
async def generate_with_history(history: List[Dict[str, str]], system_prompt: str) -> str:
|
| 59 |
+
contents = []
|
| 60 |
+
for msg in history:
|
| 61 |
+
role = "user" if msg["role"] == "user" else "model"
|
| 62 |
+
contents.append({"role": role, "parts": [{"text": msg["content"]}]})
|
| 63 |
+
|
| 64 |
+
if contents and contents[-1]["role"] == "user":
|
| 65 |
+
contents[-1]["parts"][0]["text"] += f"\n\nSYSTEM INSTRUCTION: {system_prompt}"
|
| 66 |
+
else:
|
| 67 |
+
contents.append({"role": "user", "parts": [{"text": f"SYSTEM INSTRUCTION: {system_prompt}"}]})
|
| 68 |
+
|
| 69 |
+
return await LLMService._send_request({"contents": contents})
|
| 70 |
+
|
| 71 |
+
@staticmethod
|
| 72 |
+
async def generate_interview_followup(prev_question: str, answer: str) -> Dict[str, Any]:
|
| 73 |
+
prompt = f"""
|
| 74 |
+
SYSTEM ROLE: Technical Recruiter.
|
| 75 |
+
Context: Q: "{prev_question}" A: "{answer}"
|
| 76 |
+
Task: 1 sentence feedback, 1 probing follow-up question.
|
| 77 |
+
Return JSON: {{ "feedback": "...", "followup_question": "..." }}
|
| 78 |
+
"""
|
| 79 |
+
raw = await LLMService.generate(prompt)
|
| 80 |
+
try:
|
| 81 |
+
return json.loads(LLMService._clean_json_string(raw))
|
| 82 |
+
except:
|
| 83 |
+
return {"feedback": "Thank you.", "followup_question": "Can you elaborate?"}
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
async def generate_interview_feedback(history: List[Dict[str, str]]) -> Dict[str, Any]:
|
| 87 |
+
history_text = "\n".join([f"{h['role'].upper()}: {h['content']}" for h in history])
|
| 88 |
+
prompt = f"""
|
| 89 |
+
Analyze this interview transcript:
|
| 90 |
+
{history_text}
|
| 91 |
+
|
| 92 |
+
Return JSON:
|
| 93 |
+
{{
|
| 94 |
+
"summary": {{
|
| 95 |
+
"strengths": ["pt1", "pt2"],
|
| 96 |
+
"weaknesses": ["pt1", "pt2"],
|
| 97 |
+
"overall_performance": {{
|
| 98 |
+
"technical_knowledge": "Good/Fair/Poor",
|
| 99 |
+
"communication_speed": "Fast/Avg/Slow",
|
| 100 |
+
"grammar_usage": "Good/Bad",
|
| 101 |
+
"recommendation": "Hire/No Hire"
|
| 102 |
+
}}
|
| 103 |
+
}}
|
| 104 |
+
}}
|
| 105 |
+
"""
|
| 106 |
+
raw = await LLMService.generate(prompt)
|
| 107 |
+
try:
|
| 108 |
+
return json.loads(LLMService._clean_json_string(raw))
|
| 109 |
+
except:
|
| 110 |
+
return {"summary": {}}
|
| 111 |
+
|
| 112 |
+
@staticmethod
|
| 113 |
+
async def check_relevance(question: str, answer: str) -> Dict[str, Any]:
|
| 114 |
+
prompt = f"""
|
| 115 |
+
Question: "{question}" Answer: "{answer}"
|
| 116 |
+
Is the answer logically relevant?
|
| 117 |
+
Return JSON: {{ "is_relevant": true/false, "reason": "..." }}
|
| 118 |
+
"""
|
| 119 |
+
raw = await LLMService.generate(prompt)
|
| 120 |
+
try:
|
| 121 |
+
return json.loads(LLMService._clean_json_string(raw))
|
| 122 |
+
except:
|
| 123 |
+
return {"is_relevant": True}
|
| 124 |
+
|
| 125 |
+
@staticmethod
|
| 126 |
+
async def analyze_phoneme_quality(target: str, user: str, text: str) -> Dict[str, Any]:
|
| 127 |
+
prompt = f"""
|
| 128 |
+
Phonetic Analysis.
|
| 129 |
+
Text: "{text}"
|
| 130 |
+
Target IPA: {target}
|
| 131 |
+
User IPA: {user}
|
| 132 |
+
|
| 133 |
+
Return JSON:
|
| 134 |
+
{{
|
| 135 |
+
"native_understandable": true/false,
|
| 136 |
+
"overall_feedback": "string",
|
| 137 |
+
"specific_issues": [{{"phoneme": "p", "issue": "desc", "suggestion": "tip"}}],
|
| 138 |
+
"strengths": ["list"],
|
| 139 |
+
"improvement_tips": ["list"]
|
| 140 |
+
}}
|
| 141 |
+
"""
|
| 142 |
+
raw = await LLMService.generate(prompt)
|
| 143 |
+
try:
|
| 144 |
+
return json.loads(LLMService._clean_json_string(raw))
|
| 145 |
+
except:
|
| 146 |
+
return {}
|
app/services/material_service.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import io
|
| 3 |
+
from app.repositories.material_repository import MaterialRepository
|
| 4 |
+
from app.core.exceptions import AppError, NotFoundError
|
| 5 |
+
from app.utils.time_utils import TimeUtils
|
| 6 |
+
|
| 7 |
+
class MaterialService:
|
| 8 |
+
def __init__(self, repo: MaterialRepository):
|
| 9 |
+
self.repo = repo
|
| 10 |
+
|
| 11 |
+
def _validate_columns(self, df: pd.DataFrame, required: list):
|
| 12 |
+
df.columns = df.columns.str.lower().str.strip()
|
| 13 |
+
missing = [col for col in required if col not in df.columns]
|
| 14 |
+
if missing:
|
| 15 |
+
raise AppError(status_code=400, detail=f"Missing columns: {', '.join(missing)}")
|
| 16 |
+
|
| 17 |
+
def _validate_phoneme_content(self, phoneme: str, category: str, text: str):
|
| 18 |
+
"""Strict validation ensuring phonemes match category"""
|
| 19 |
+
if "-" in category:
|
| 20 |
+
target_phonemes = category.split("-")
|
| 21 |
+
for p in target_phonemes:
|
| 22 |
+
if p not in phoneme:
|
| 23 |
+
raise AppError(status_code=400, detail=f"Phoneme transcription must contain all targets from category '{category}'. Missing: {p}")
|
| 24 |
+
else:
|
| 25 |
+
if category not in phoneme:
|
| 26 |
+
raise AppError(status_code=400, detail=f"Phoneme transcription must contain target '{category}'")
|
| 27 |
+
|
| 28 |
+
async def get_phoneme_materials_list(self, page: int, limit: int, search: str):
|
| 29 |
+
skip = (page - 1) * limit
|
| 30 |
+
items, total = await self.repo.get_phoneme_materials_paginated(skip, limit, search)
|
| 31 |
+
|
| 32 |
+
data = []
|
| 33 |
+
for item in items:
|
| 34 |
+
data.append({
|
| 35 |
+
"phoneme": item[0],
|
| 36 |
+
"phonemeCategory": item[0],
|
| 37 |
+
"totalWords": item[1],
|
| 38 |
+
"lastUpdate": TimeUtils.format_to_wib(item[2])
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
return {
|
| 42 |
+
"phonemeMaterials": data,
|
| 43 |
+
"pagination": {
|
| 44 |
+
"currentPage": page,
|
| 45 |
+
"totalRecords": total,
|
| 46 |
+
"totalPages": (total + limit - 1) // limit
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
async def get_exercise_materials_list(self, page: int, limit: int, search: str):
|
| 51 |
+
skip = (page - 1) * limit
|
| 52 |
+
items, total = await self.repo.get_exercise_materials_paginated(skip, limit, search)
|
| 53 |
+
|
| 54 |
+
data = []
|
| 55 |
+
for item in items:
|
| 56 |
+
data.append({
|
| 57 |
+
"phonemeCategory": item[0],
|
| 58 |
+
"totalSentence": item[1],
|
| 59 |
+
"lastUpdate": TimeUtils.format_to_wib(item[2])
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
return {
|
| 63 |
+
"exercisePhonemes": data,
|
| 64 |
+
"pagination": {
|
| 65 |
+
"currentPage": page,
|
| 66 |
+
"totalRecords": total,
|
| 67 |
+
"totalPages": (total + limit - 1) // limit
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async def get_exam_materials_list(self, page: int, limit: int, search: str):
|
| 72 |
+
skip = (page - 1) * limit
|
| 73 |
+
items, total = await self.repo.get_exam_materials_paginated(skip, limit, search)
|
| 74 |
+
|
| 75 |
+
data = []
|
| 76 |
+
for item in items:
|
| 77 |
+
data.append({
|
| 78 |
+
"phonemeCategory": item[0],
|
| 79 |
+
"totalExam": item[1],
|
| 80 |
+
"lastUpdate": TimeUtils.format_to_wib(item[2])
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
"examPhonemes": data,
|
| 85 |
+
"pagination": {
|
| 86 |
+
"currentPage": page,
|
| 87 |
+
"totalRecords": total,
|
| 88 |
+
"totalPages": (total + limit - 1) // limit
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
async def import_words_from_excel(self, file_content: bytes):
|
| 93 |
+
try:
|
| 94 |
+
df = pd.read_excel(io.BytesIO(file_content))
|
| 95 |
+
self._validate_columns(df, ['kategori', 'kata', 'fonem', 'arti', 'definisi'])
|
| 96 |
+
|
| 97 |
+
success, errors = 0, []
|
| 98 |
+
for idx, row in df.iterrows():
|
| 99 |
+
try:
|
| 100 |
+
cat = str(row['kategori']).strip()
|
| 101 |
+
word = str(row['kata']).strip()
|
| 102 |
+
phon = str(row['fonem']).strip()
|
| 103 |
+
|
| 104 |
+
self._validate_phoneme_content(phon, cat, word)
|
| 105 |
+
|
| 106 |
+
data = {
|
| 107 |
+
"kategori": cat, "kata": word, "fonem": phon,
|
| 108 |
+
"meaning": str(row['arti']).strip(),
|
| 109 |
+
"definition": str(row['definisi']).strip()
|
| 110 |
+
}
|
| 111 |
+
await self.repo.create_word(data)
|
| 112 |
+
success += 1
|
| 113 |
+
except Exception as e:
|
| 114 |
+
errors.append({"row": idx + 2, "error": str(e)})
|
| 115 |
+
|
| 116 |
+
return {"successCount": success, "errorCount": len(errors), "errors": errors}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
raise AppError(status_code=400, detail=f"Import failed: {str(e)}")
|
| 119 |
+
|
| 120 |
+
async def import_sentences_from_excel(self, file_content: bytes):
|
| 121 |
+
try:
|
| 122 |
+
df = pd.read_excel(io.BytesIO(file_content))
|
| 123 |
+
self._validate_columns(df, ['kategori', 'kalimat', 'fonem'])
|
| 124 |
+
|
| 125 |
+
success, errors = 0, []
|
| 126 |
+
for idx, row in df.iterrows():
|
| 127 |
+
try:
|
| 128 |
+
cat = str(row['kategori']).strip()
|
| 129 |
+
sent = str(row['kalimat']).strip()
|
| 130 |
+
phon = str(row['fonem']).strip()
|
| 131 |
+
|
| 132 |
+
if len(sent.split()) < 4:
|
| 133 |
+
raise ValueError("Sentence must have at least 4 words")
|
| 134 |
+
|
| 135 |
+
self._validate_phoneme_content(phon, cat, sent)
|
| 136 |
+
|
| 137 |
+
await self.repo.create_sentence({
|
| 138 |
+
"kategori": cat, "kalimat": sent, "fonem": phon
|
| 139 |
+
})
|
| 140 |
+
success += 1
|
| 141 |
+
except Exception as e:
|
| 142 |
+
errors.append({"row": idx + 2, "error": str(e)})
|
| 143 |
+
|
| 144 |
+
return {"successCount": success, "errorCount": len(errors), "errors": errors}
|
| 145 |
+
except Exception as e:
|
| 146 |
+
raise AppError(status_code=400, detail=f"Import failed: {str(e)}")
|
| 147 |
+
|
| 148 |
+
async def import_exams_from_excel(self, file_content: bytes):
|
| 149 |
+
try:
|
| 150 |
+
df = pd.read_excel(io.BytesIO(file_content))
|
| 151 |
+
req_cols = ['kategori'] + [f'kalimat_{i}' for i in range(1, 11)] + [f'fonem_{i}' for i in range(1, 11)]
|
| 152 |
+
self._validate_columns(df, req_cols)
|
| 153 |
+
|
| 154 |
+
success, errors = 0, []
|
| 155 |
+
for idx, row in df.iterrows():
|
| 156 |
+
try:
|
| 157 |
+
category = str(row['kategori']).strip()
|
| 158 |
+
if "-" not in category:
|
| 159 |
+
raise ValueError("Exam category must be a pair (e.g. i-I)")
|
| 160 |
+
|
| 161 |
+
items = []
|
| 162 |
+
for i in range(1, 11):
|
| 163 |
+
sent = str(row[f'kalimat_{i}']).strip()
|
| 164 |
+
phon = str(row[f'fonem_{i}']).strip()
|
| 165 |
+
if not sent or not phon:
|
| 166 |
+
raise ValueError(f"Missing data at index {i}")
|
| 167 |
+
|
| 168 |
+
self._validate_phoneme_content(phon, category, sent)
|
| 169 |
+
items.append({"sentence": sent, "phoneme": phon})
|
| 170 |
+
|
| 171 |
+
await self.repo.create_exam_set(category, items)
|
| 172 |
+
success += 1
|
| 173 |
+
except Exception as e:
|
| 174 |
+
errors.append({"row": idx + 2, "error": str(e)})
|
| 175 |
+
|
| 176 |
+
return {"successCount": success, "errorCount": len(errors), "errors": errors}
|
| 177 |
+
except Exception as e:
|
| 178 |
+
raise AppError(status_code=400, detail=f"Import failed: {str(e)}")
|
| 179 |
+
|
| 180 |
+
async def update_word(self, id: int, data: dict):
|
| 181 |
+
word = await self.repo.get_word_by_id(id)
|
| 182 |
+
if not word: raise NotFoundError("Word")
|
| 183 |
+
await self.repo.update_word(id, data)
|
| 184 |
+
|
| 185 |
+
async def delete_word(self, id: int):
|
| 186 |
+
word = await self.repo.get_word_by_id(id)
|
| 187 |
+
if not word: raise NotFoundError("Word")
|
| 188 |
+
await self.repo.delete_word(id)
|
| 189 |
+
|
| 190 |
+
async def create_sentence(self, data: dict):
|
| 191 |
+
if len(data['sentence'].split()) < 4:
|
| 192 |
+
raise AppError(status_code=400, detail="Sentence too short")
|
| 193 |
+
return await self.repo.create_sentence(data)
|
| 194 |
+
|
| 195 |
+
async def update_sentence(self, id: int, data: dict):
|
| 196 |
+
sent = await self.repo.get_sentence_by_id(id)
|
| 197 |
+
if not sent: raise NotFoundError("Sentence")
|
| 198 |
+
await self.repo.update_sentence(id, data)
|
| 199 |
+
|
| 200 |
+
async def delete_sentence(self, id: int):
|
| 201 |
+
sent = await self.repo.get_sentence_by_id(id)
|
| 202 |
+
if not sent: raise NotFoundError("Sentence")
|
| 203 |
+
await self.repo.delete_sentence(id)
|
| 204 |
+
|
| 205 |
+
async def delete_exam(self, id: int):
|
| 206 |
+
exam = await self.repo.get_exam_header(id)
|
| 207 |
+
if not exam: raise NotFoundError("Exam")
|
| 208 |
+
await self.repo.delete_exam(id)
|
| 209 |
+
|
| 210 |
+
async def update_exam_sentences(self, exam_id: int, items: list):
|
| 211 |
+
for item in items:
|
| 212 |
+
if len(item.sentence.split()) < 4:
|
| 213 |
+
raise AppError(status_code=400, detail="Exam sentence must be >= 4 words")
|
| 214 |
+
items_dict = [item.model_dump() for item in items]
|
| 215 |
+
await self.repo.update_exam_sentences(exam_id, items_dict)
|
| 216 |
+
|
| 217 |
+
async def update_interview(self, id: int, question: str):
|
| 218 |
+
q = await self.repo.get_interview_question_by_id(id)
|
| 219 |
+
if not q: raise NotFoundError("Question")
|
| 220 |
+
await self.repo.update_interview_question(id, question)
|
| 221 |
+
|
| 222 |
+
async def delete_interview(self, id: int):
|
| 223 |
+
q = await self.repo.get_interview_question_by_id(id)
|
| 224 |
+
if not q: raise NotFoundError("Question")
|
| 225 |
+
await self.repo.delete_interview_question(id)
|
| 226 |
+
|
| 227 |
+
async def toggle_interview(self, id: int):
|
| 228 |
+
q = await self.repo.get_interview_question_by_id(id)
|
| 229 |
+
if not q: raise NotFoundError("Question")
|
| 230 |
+
return await self.repo.toggle_interview_status(id)
|
| 231 |
+
|
| 232 |
+
async def swap_interview(self, id: int, direction: str):
|
| 233 |
+
success = await self.repo.swap_interview_order(id, direction)
|
| 234 |
+
if not success:
|
| 235 |
+
raise AppError(status_code=400, detail="Cannot move in that direction")
|
app/services/phoneme_service.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from app.repositories.material_repository import MaterialRepository
|
| 3 |
+
from app.repositories.score_repository import ScoreRepository
|
| 4 |
+
from app.services.audio_service import AudioService
|
| 5 |
+
from app.services.llm_service import LLMService
|
| 6 |
+
from app.utils.phoneme_utils import PhonemeMatcher
|
| 7 |
+
from app.core.exceptions import NotFoundError
|
| 8 |
+
from gruut import sentences as gruut_sentences
|
| 9 |
+
|
| 10 |
+
class PhonemeService:
|
| 11 |
+
def __init__(self, material_repo: MaterialRepository, score_repo: ScoreRepository):
|
| 12 |
+
self.material_repo = material_repo
|
| 13 |
+
self.score_repo = score_repo
|
| 14 |
+
|
| 15 |
+
def _generate_phonemes_fallback(self, text: str) -> str:
|
| 16 |
+
"""Fallback to generate phonemes if DB is empty"""
|
| 17 |
+
try:
|
| 18 |
+
phonemes = []
|
| 19 |
+
for sentence in gruut_sentences(text, lang="en-us"):
|
| 20 |
+
for word in sentence.words:
|
| 21 |
+
if word.phonemes:
|
| 22 |
+
phonemes.extend(word.phonemes)
|
| 23 |
+
return " ".join(phonemes)
|
| 24 |
+
except:
|
| 25 |
+
return ""
|
| 26 |
+
|
| 27 |
+
async def process_pronunciation(self, talent_id: int, content_id: int, audio_bytes: bytes, type: str):
|
| 28 |
+
# 1. Ambil Target
|
| 29 |
+
content = await self.material_repo.get_phoneme_content(content_id, type)
|
| 30 |
+
if not content:
|
| 31 |
+
raise NotFoundError(f"Material {type}")
|
| 32 |
+
|
| 33 |
+
target_text = content.kata if type == "word" else content.kalimat
|
| 34 |
+
target_phonemes = content.fonem
|
| 35 |
+
|
| 36 |
+
# Robustness: Jika DB kosong, generate on-the-fly
|
| 37 |
+
if not target_phonemes:
|
| 38 |
+
target_phonemes = self._generate_phonemes_fallback(target_text)
|
| 39 |
+
|
| 40 |
+
# 2. Transkripsi Audio
|
| 41 |
+
try:
|
| 42 |
+
user_phonemes = await AudioService.transcribe(audio_bytes)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
# Fallback jika Audio Service error
|
| 45 |
+
print(f"Audio Service Error: {e}")
|
| 46 |
+
user_phonemes = ""
|
| 47 |
+
|
| 48 |
+
# 3. Scoring
|
| 49 |
+
alignment = PhonemeMatcher.align_phonemes(target_phonemes, user_phonemes)
|
| 50 |
+
accuracy = PhonemeMatcher.calculate_accuracy(alignment)
|
| 51 |
+
|
| 52 |
+
# 4. AI Analysis (dengan Try-Except agar tidak memblokir flow utama)
|
| 53 |
+
try:
|
| 54 |
+
ai_analysis = await LLMService.analyze_phoneme_quality(
|
| 55 |
+
target_phonemes, user_phonemes, target_text
|
| 56 |
+
)
|
| 57 |
+
except Exception:
|
| 58 |
+
ai_analysis = {"error": "AI Analysis unavailable"}
|
| 59 |
+
|
| 60 |
+
# 5. Result Object
|
| 61 |
+
result_full = {
|
| 62 |
+
"similarity_percent": f"{accuracy}%",
|
| 63 |
+
"accuracy_score": accuracy,
|
| 64 |
+
"target_phonemes": target_phonemes,
|
| 65 |
+
"user_phonemes": user_phonemes,
|
| 66 |
+
"phoneme_comparison": alignment,
|
| 67 |
+
"gemini_analysis": ai_analysis
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
# 6. Save DB
|
| 71 |
+
db_type = "Word" if type.lower() == "word" else "Sentence"
|
| 72 |
+
await self.score_repo.save_phoneme_result(
|
| 73 |
+
talent_id=talent_id,
|
| 74 |
+
soal_id=content_id,
|
| 75 |
+
type=db_type,
|
| 76 |
+
score=accuracy,
|
| 77 |
+
comparison=result_full
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
return result_full
|
| 81 |
+
|
| 82 |
+
async def get_word_by_id(self, id: int):
|
| 83 |
+
word = await self.material_repo.get_word_by_id(id)
|
| 84 |
+
if not word: raise NotFoundError("Word")
|
| 85 |
+
return {
|
| 86 |
+
"idContent": word.idmaterifonemkata,
|
| 87 |
+
"content": word.kata,
|
| 88 |
+
"meaning": word.meaning,
|
| 89 |
+
"definition": word.definition,
|
| 90 |
+
"phoneme": word.fonem,
|
| 91 |
+
"phoneme_category": word.kategori
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
async def get_sentence_by_id(self, id: int):
|
| 95 |
+
sent = await self.material_repo.get_sentence_by_id(id)
|
| 96 |
+
if not sent: raise NotFoundError("Sentence")
|
| 97 |
+
return {
|
| 98 |
+
"idContent": sent.idmaterifonemkalimat,
|
| 99 |
+
"content": sent.kalimat,
|
| 100 |
+
"phoneme": sent.fonem,
|
| 101 |
+
"phoneme_category": sent.kategori
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
async def get_random_word(self, phoneme: str):
|
| 105 |
+
word = await self.material_repo.get_random_word_by_phoneme(phoneme)
|
| 106 |
+
if not word: return None
|
| 107 |
+
return {
|
| 108 |
+
"idContent": word.idmaterifonemkata,
|
| 109 |
+
"content": word.kata,
|
| 110 |
+
"phoneme": word.fonem,
|
| 111 |
+
"phoneme_category": word.kategori
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async def get_random_sentence(self, phoneme: str):
|
| 115 |
+
sent = await self.material_repo.get_random_sentence_by_phoneme(phoneme)
|
| 116 |
+
if not sent: return None
|
| 117 |
+
return {
|
| 118 |
+
"idContent": sent.idmaterifonemkalimat,
|
| 119 |
+
"content": sent.kalimat,
|
| 120 |
+
"phoneme": sent.fonem,
|
| 121 |
+
"phoneme_category": sent.kategori
|
| 122 |
+
}
|
app/services/profile_service.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.talent_repository import TalentRepository
|
| 3 |
+
from app.repositories.dashboard_repository import DashboardRepository
|
| 4 |
+
from app.core.exceptions import NotFoundError
|
| 5 |
+
from app.utils.time_utils import TimeUtils
|
| 6 |
+
from sqlalchemy import select, func
|
| 7 |
+
from app.models.models import Materifonemkata, Materifonemkalimat
|
| 8 |
+
|
| 9 |
+
class ProfileService:
|
| 10 |
+
def __init__(self, db: AsyncSession):
|
| 11 |
+
self.talent_repo = TalentRepository(db)
|
| 12 |
+
self.dash_repo = DashboardRepository(db)
|
| 13 |
+
self.db = db
|
| 14 |
+
|
| 15 |
+
async def get_mobile_profile(self, talent_id: int):
|
| 16 |
+
# 1. Validasi Talent
|
| 17 |
+
talent = await self.talent_repo.get_by_id(talent_id)
|
| 18 |
+
if not talent:
|
| 19 |
+
raise NotFoundError("Talent")
|
| 20 |
+
|
| 21 |
+
# 2. Ambil Statistik Dasar (Exam)
|
| 22 |
+
progress_stats = await self.talent_repo.get_talent_progress_stats(talent_id)
|
| 23 |
+
|
| 24 |
+
# 3. Ambil Statistik Pronunciation (Avg Score & Count)
|
| 25 |
+
avg_pronunciation = await self.dash_repo.get_user_avg_pronunciation(talent_id)
|
| 26 |
+
pron_completed_count = await self.dash_repo.get_user_phoneme_counts(talent_id)
|
| 27 |
+
|
| 28 |
+
# Hitung total materi phoneme yang tersedia (untuk progress bar)
|
| 29 |
+
count_word = await self.db.scalar(select(func.count(Materifonemkata.idmaterifonemkata)))
|
| 30 |
+
count_sent = await self.db.scalar(select(func.count(Materifonemkalimat.idmaterifonemkalimat)))
|
| 31 |
+
total_phoneme_material = (count_word or 0) + (count_sent or 0)
|
| 32 |
+
|
| 33 |
+
# 4. Ambil Statistik Conversation (WPM & Count)
|
| 34 |
+
conv_stats = await self.dash_repo.get_user_conversation_stats(talent_id)
|
| 35 |
+
|
| 36 |
+
# 5. Ambil Statistik Interview (WPM & Count)
|
| 37 |
+
int_stats = await self.dash_repo.get_user_interview_stats(talent_id)
|
| 38 |
+
|
| 39 |
+
# 6. Hitung Streak (Logic Penuh)
|
| 40 |
+
activity_dates = await self.dash_repo.get_user_activity_dates(talent_id)
|
| 41 |
+
highest_streak, current_streak = TimeUtils.calculate_streaks(activity_dates)
|
| 42 |
+
|
| 43 |
+
return {
|
| 44 |
+
"name": talent.nama,
|
| 45 |
+
"jobTitle": talent.role,
|
| 46 |
+
"email": talent.email,
|
| 47 |
+
"pretestScore": talent.pretest_score or 0,
|
| 48 |
+
|
| 49 |
+
# Exam Stats
|
| 50 |
+
"highestExam": progress_stats['highest_exam'] or 0,
|
| 51 |
+
"lastTest": progress_stats['latest_exam'] or 0,
|
| 52 |
+
|
| 53 |
+
# Average Scores
|
| 54 |
+
"averagePronunciation": round(avg_pronunciation, 2),
|
| 55 |
+
"averageWPMConversation": round(conv_stats["avg_wpm"], 2),
|
| 56 |
+
"averageWPMInterview": round(int_stats["avg_wpm"], 2),
|
| 57 |
+
|
| 58 |
+
# Streaks
|
| 59 |
+
"highestStreak": highest_streak,
|
| 60 |
+
"currentStreak": current_streak,
|
| 61 |
+
|
| 62 |
+
# Activity Counts
|
| 63 |
+
"activity": {
|
| 64 |
+
"phonemeCompleted": pron_completed_count,
|
| 65 |
+
"phonemeTotal": total_phoneme_material,
|
| 66 |
+
"conversationCompleted": conv_stats["count"],
|
| 67 |
+
"interviewCompleted": int_stats["count"]
|
| 68 |
+
}
|
| 69 |
+
}
|
app/services/talent_service.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 2 |
+
from app.repositories.talent_repository import TalentRepository
|
| 3 |
+
from app.schemas.talent import TalentUpdate
|
| 4 |
+
from app.schemas.auth import TalentCreate
|
| 5 |
+
from app.core.exceptions import NotFoundError, AppError, DuplicateError
|
| 6 |
+
from app.utils.time_utils import TimeUtils
|
| 7 |
+
from passlib.context import CryptContext
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import io
|
| 10 |
+
|
| 11 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 12 |
+
|
| 13 |
+
class TalentService:
|
| 14 |
+
def __init__(self, db: AsyncSession):
|
| 15 |
+
self.repo = TalentRepository(db)
|
| 16 |
+
|
| 17 |
+
async def get_talents_list(self, page: int, limit: int, search: str):
|
| 18 |
+
skip = (page - 1) * limit
|
| 19 |
+
talents, total = await self.repo.get_talents_paginated(skip, limit, search)
|
| 20 |
+
results = []
|
| 21 |
+
for t in talents:
|
| 22 |
+
stats = await self.repo.get_talent_progress_stats(t.idtalent)
|
| 23 |
+
latest = stats['latest_exam'] if stats['latest_exam'] is not None else 0
|
| 24 |
+
highest = stats['highest_exam'] if stats['highest_exam'] is not None else 0
|
| 25 |
+
results.append({
|
| 26 |
+
"id": t.idtalent, "talentName": t.nama, "email": t.email, "role": t.role,
|
| 27 |
+
"pretest": f"{t.pretest_score:.0f}%" if t.pretest_score is not None else "N/A",
|
| 28 |
+
"highestExam": f"{highest:.0f}%", "progress": f"{stats['progress']:.0f}%"
|
| 29 |
+
})
|
| 30 |
+
return {"data": results, "total": total, "page": page, "size": limit}
|
| 31 |
+
|
| 32 |
+
async def get_talent_detail(self, talent_id: int):
|
| 33 |
+
talent = await self.repo.get_by_id(talent_id)
|
| 34 |
+
if not talent: raise NotFoundError("Talent")
|
| 35 |
+
stats = await self.repo.get_talent_progress_stats(talent_id)
|
| 36 |
+
latest = stats['latest_exam'] if stats['latest_exam'] is not None else 0
|
| 37 |
+
highest = stats['highest_exam'] if stats['highest_exam'] is not None else 0
|
| 38 |
+
return {
|
| 39 |
+
"talentId": f"TLT{talent.idtalent:03d}", "nama": talent.nama, "role": talent.role, "email": talent.email,
|
| 40 |
+
"lastExam": f"{latest:.0f}%" if stats['latest_exam'] is not None else "N/A",
|
| 41 |
+
"highestExam": f"{highest:.0f}%" if stats['highest_exam'] is not None else "N/A",
|
| 42 |
+
"progress": f"{stats['progress']:.0f}%"
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async def create_talent(self, data: TalentCreate):
|
| 46 |
+
# 1. Cek Email Duplikat
|
| 47 |
+
existing = await self.repo.get_by_email(data.email)
|
| 48 |
+
if existing:
|
| 49 |
+
raise DuplicateError(f"Email {data.email} sudah terdaftar")
|
| 50 |
+
|
| 51 |
+
# 2. Hash Password
|
| 52 |
+
hashed_pw = pwd_context.hash(data.password)
|
| 53 |
+
|
| 54 |
+
# 3. Simpan ke DB
|
| 55 |
+
new_talent_data = {
|
| 56 |
+
"nama": data.nama,
|
| 57 |
+
"email": data.email,
|
| 58 |
+
"password": hashed_pw,
|
| 59 |
+
"role": data.role
|
| 60 |
+
}
|
| 61 |
+
return await self.repo.create(new_talent_data)
|
| 62 |
+
|
| 63 |
+
async def delete_talent(self, talent_id: int):
|
| 64 |
+
talent = await self.repo.get_by_id(talent_id)
|
| 65 |
+
if not talent: raise NotFoundError("Talent")
|
| 66 |
+
await self.repo.delete(talent)
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
async def update_talent(self, talent_id: int, data: TalentUpdate):
|
| 70 |
+
talent = await self.repo.get_by_id(talent_id)
|
| 71 |
+
if not talent: raise NotFoundError("Talent")
|
| 72 |
+
update_data = data.model_dump(exclude_unset=True)
|
| 73 |
+
return await self.repo.update(talent, update_data)
|
| 74 |
+
|
| 75 |
+
async def change_password(self, talent_id: int, new_password: str):
|
| 76 |
+
talent = await self.repo.get_by_id(talent_id)
|
| 77 |
+
if not talent: raise NotFoundError("Talent")
|
| 78 |
+
hashed = pwd_context.hash(new_password)
|
| 79 |
+
await self.repo.update(talent, {"password": hashed})
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
async def import_talents_from_excel(self, file_content: bytes):
|
| 83 |
+
try:
|
| 84 |
+
df = pd.read_excel(io.BytesIO(file_content))
|
| 85 |
+
required = ['nama', 'email', 'role', 'password']
|
| 86 |
+
df.columns = df.columns.str.lower().str.strip()
|
| 87 |
+
missing = [col for col in required if col not in df.columns]
|
| 88 |
+
if missing: raise AppError(status_code=400, detail=f"Missing columns: {', '.join(missing)}")
|
| 89 |
+
success, errors = 0, []
|
| 90 |
+
for idx, row in df.iterrows():
|
| 91 |
+
try:
|
| 92 |
+
email = str(row['email']).strip()
|
| 93 |
+
if await self.repo.get_by_email(email): raise ValueError(f"Email {email} already exists")
|
| 94 |
+
data = {"nama": str(row['nama']).strip(), "email": email, "role": str(row['role']).strip(), "password": pwd_context.hash(str(row['password']).strip())}
|
| 95 |
+
await self.repo.create(data)
|
| 96 |
+
success += 1
|
| 97 |
+
except Exception as e:
|
| 98 |
+
errors.append({"row": idx + 2, "error": str(e)})
|
| 99 |
+
return {"successCount": success, "errorCount": len(errors), "errors": errors}
|
| 100 |
+
except Exception as e:
|
| 101 |
+
raise AppError(status_code=400, detail=f"Import failed: {str(e)}")
|
| 102 |
+
|
| 103 |
+
async def get_phoneme_progress(self, talent_id: int, type_latihan: str, page: int, limit: int):
|
| 104 |
+
skip = (page - 1) * limit
|
| 105 |
+
items, total = await self.repo.get_phoneme_history_paginated(talent_id, type_latihan, skip, limit)
|
| 106 |
+
data = []
|
| 107 |
+
for item in items:
|
| 108 |
+
data.append({
|
| 109 |
+
"phonemeCategory": item["category"], "content": item["content"],
|
| 110 |
+
"score": f"{item['score']:.0f}%", "date": TimeUtils.format_to_wib(item["date"])
|
| 111 |
+
})
|
| 112 |
+
return {"data": data, "total": total, "page": page}
|
| 113 |
+
|
| 114 |
+
async def get_exam_progress(self, talent_id: int, page: int, limit: int):
|
| 115 |
+
skip = (page - 1) * limit
|
| 116 |
+
exams, total = await self.repo.get_exam_history_paginated(talent_id, skip, limit)
|
| 117 |
+
data = []
|
| 118 |
+
for ex in exams:
|
| 119 |
+
data.append({
|
| 120 |
+
"examId": ex.idujian, "phonemeCategory": ex.kategori,
|
| 121 |
+
"score": f"{ex.nilai or 0:.0f}%", "date": TimeUtils.format_to_wib(ex.waktuujian)
|
| 122 |
+
})
|
| 123 |
+
return {"data": data, "total": total, "page": page}
|
| 124 |
+
|
| 125 |
+
async def get_exam_attempt_detail(self, talent_id: int, attempt_id: int):
|
| 126 |
+
exam, details = await self.repo.get_exam_attempt_detail(talent_id, attempt_id)
|
| 127 |
+
if not exam: raise NotFoundError("Exam Attempt")
|
| 128 |
+
sentences_data = []
|
| 129 |
+
for row in details:
|
| 130 |
+
detail, kalimat = row
|
| 131 |
+
sentences_data.append({"sentence": kalimat.kalimat, "phonemeTranscription": kalimat.fonem, "score": f"{detail.nilai or 0:.0f}%"})
|
| 132 |
+
return {
|
| 133 |
+
"attemptId": f"ATT{exam.idujian:03d}", "phonemeCategory": exam.kategori,
|
| 134 |
+
"totalScore": f"{exam.nilai or 0:.0f}%", "date": TimeUtils.format_to_wib(exam.waktuujian),
|
| 135 |
+
"sentences": sentences_data
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
async def get_conversation_progress(self, talent_id: int, page: int, limit: int):
|
| 139 |
+
skip = (page - 1) * limit
|
| 140 |
+
items, total = await self.repo.get_conversation_history_paginated(talent_id, skip, limit)
|
| 141 |
+
data = []
|
| 142 |
+
for row in items:
|
| 143 |
+
res, topic = row
|
| 144 |
+
data.append({
|
| 145 |
+
"topic": topic or "General", "wpm": f"{res.wpm or 0:.0f}",
|
| 146 |
+
"grammarIssue": res.grammar or "No issues", "date": TimeUtils.format_to_wib(res.waktulatihan)
|
| 147 |
+
})
|
| 148 |
+
return {"data": data, "total": total, "page": page}
|
| 149 |
+
|
| 150 |
+
async def get_interview_progress(self, talent_id: int, page: int, limit: int):
|
| 151 |
+
skip = (page - 1) * limit
|
| 152 |
+
items, total = await self.repo.get_interview_history_paginated(talent_id, skip, limit)
|
| 153 |
+
data = []
|
| 154 |
+
for idx, item in enumerate(items):
|
| 155 |
+
attempt_no = total - skip - idx
|
| 156 |
+
data.append({
|
| 157 |
+
"attempt": attempt_no, "attemptId": item.idhasilinterview, "wordProducePerMinute": f"{item.wpm or 0:.0f}",
|
| 158 |
+
"feedback": item.feedback[:50] + "..." if item.feedback else "No feedback", "date": TimeUtils.format_to_wib(item.waktulatihan)
|
| 159 |
+
})
|
| 160 |
+
return {"data": data, "total": total, "page": page}
|
| 161 |
+
|
| 162 |
+
async def get_interview_detail(self, talent_id: int, attempt_id: int):
|
| 163 |
+
interview = await self.repo.get_interview_detail(talent_id, attempt_id)
|
| 164 |
+
if not interview: raise NotFoundError("Interview")
|
| 165 |
+
return {"attemptId": attempt_id, "date": TimeUtils.format_to_wib(interview.waktulatihan), "feedback": interview.feedback, "wpm": f"{interview.wpm:.0f}", "grammar": interview.grammar}
|
app/utils/calculation_utils.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class CalculationHelper:
|
| 2 |
+
@staticmethod
|
| 3 |
+
def calculate_wpm(text: str, duration: str) -> float:
|
| 4 |
+
"""Kalkulasi Words Per Minute"""
|
| 5 |
+
try:
|
| 6 |
+
words = len(text.split())
|
| 7 |
+
duration = duration.strip()
|
| 8 |
+
|
| 9 |
+
if ':' in duration:
|
| 10 |
+
time_parts = duration.split(':')
|
| 11 |
+
if len(time_parts) == 2:
|
| 12 |
+
minutes, seconds = map(int, time_parts)
|
| 13 |
+
total_seconds = minutes * 60 + seconds
|
| 14 |
+
else:
|
| 15 |
+
return 0.0
|
| 16 |
+
else:
|
| 17 |
+
total_seconds = int(duration)
|
| 18 |
+
|
| 19 |
+
if total_seconds <= 0:
|
| 20 |
+
return 0.0
|
| 21 |
+
|
| 22 |
+
wpm = (words / total_seconds) * 60
|
| 23 |
+
return round(wpm, 2)
|
| 24 |
+
except Exception:
|
| 25 |
+
return 0.0
|
app/utils/phoneme_utils.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from difflib import SequenceMatcher
|
| 2 |
+
from typing import List, Dict, Tuple
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
class PhonemeMatcher:
|
| 6 |
+
SIMILAR_PHONEMES = settings.SIMILAR_PHONEMES
|
| 7 |
+
ALL_PHONEMES = settings.VOWEL_PHONEMES + settings.DIPHTHONG_PHONEMES + settings.CONSONANT_PHONEMES
|
| 8 |
+
|
| 9 |
+
@classmethod
|
| 10 |
+
def normalize_phonemes(cls, phoneme_str: str) -> List[str]:
|
| 11 |
+
if not phoneme_str:
|
| 12 |
+
return []
|
| 13 |
+
|
| 14 |
+
normalized_str = phoneme_str
|
| 15 |
+
for key, value in settings.TIE_BAR_NORMALIZATION.items():
|
| 16 |
+
normalized_str = normalized_str.replace(key, value)
|
| 17 |
+
|
| 18 |
+
return normalized_str.split()
|
| 19 |
+
|
| 20 |
+
@classmethod
|
| 21 |
+
def get_similar_phonemes(cls, phoneme: str) -> List[str]:
|
| 22 |
+
if phoneme not in cls.ALL_PHONEMES:
|
| 23 |
+
return []
|
| 24 |
+
|
| 25 |
+
direct_similars = set(cls.SIMILAR_PHONEMES.get(phoneme, []))
|
| 26 |
+
inverse_similars = set()
|
| 27 |
+
for key, values in cls.SIMILAR_PHONEMES.items():
|
| 28 |
+
if phoneme in values:
|
| 29 |
+
inverse_similars.add(key)
|
| 30 |
+
inverse_similars.update(cls.SIMILAR_PHONEMES.get(key, []))
|
| 31 |
+
|
| 32 |
+
all_similars = direct_similars.union(inverse_similars)
|
| 33 |
+
all_similars.discard(phoneme)
|
| 34 |
+
return sorted(list(all_similars))
|
| 35 |
+
|
| 36 |
+
@classmethod
|
| 37 |
+
def is_similar(cls, target: str, user: str) -> bool:
|
| 38 |
+
if target == user:
|
| 39 |
+
return True
|
| 40 |
+
similars = cls.get_similar_phonemes(target)
|
| 41 |
+
return user in similars
|
| 42 |
+
|
| 43 |
+
@classmethod
|
| 44 |
+
def get_status_score(cls, target: str, user: str) -> Tuple[str, int]:
|
| 45 |
+
if not target and not user: return "correct", 100
|
| 46 |
+
if not target and user: return "extra", 0
|
| 47 |
+
if target and not user: return "missing", 0
|
| 48 |
+
|
| 49 |
+
if target == user: return "correct", 100
|
| 50 |
+
if cls.is_similar(target, user): return "similar", 75
|
| 51 |
+
|
| 52 |
+
return "incorrect", 0
|
| 53 |
+
|
| 54 |
+
@classmethod
|
| 55 |
+
def align_phonemes(cls, target_str: str, user_str: str) -> List[Dict]:
|
| 56 |
+
target_list = cls.normalize_phonemes(target_str)
|
| 57 |
+
user_list = cls.normalize_phonemes(user_str)
|
| 58 |
+
|
| 59 |
+
matcher = SequenceMatcher(None, target_list, user_list)
|
| 60 |
+
alignment = []
|
| 61 |
+
|
| 62 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 63 |
+
if tag == 'equal':
|
| 64 |
+
for k in range(i2 - i1):
|
| 65 |
+
t = target_list[i1+k]
|
| 66 |
+
u = user_list[j1+k]
|
| 67 |
+
status, score = cls.get_status_score(t, u)
|
| 68 |
+
alignment.append({"target": t, "user": u, "status": status, "similarity": score})
|
| 69 |
+
elif tag == 'replace':
|
| 70 |
+
len_target = i2 - i1
|
| 71 |
+
len_user = j2 - j1
|
| 72 |
+
max_len = max(len_target, len_user)
|
| 73 |
+
for k in range(max_len):
|
| 74 |
+
t = target_list[i1+k] if k < len_target else ""
|
| 75 |
+
u = user_list[j1+k] if k < len_user else ""
|
| 76 |
+
status, score = cls.get_status_score(t, u)
|
| 77 |
+
alignment.append({"target": t, "user": u, "status": status, "similarity": score})
|
| 78 |
+
elif tag == 'delete':
|
| 79 |
+
for k in range(i2 - i1):
|
| 80 |
+
t = target_list[i1+k]
|
| 81 |
+
status, score = cls.get_status_score(t, "")
|
| 82 |
+
alignment.append({"target": t, "user": "", "status": status, "similarity": score})
|
| 83 |
+
elif tag == 'insert':
|
| 84 |
+
for k in range(j2 - j1):
|
| 85 |
+
u = user_list[j1+k]
|
| 86 |
+
status, score = cls.get_status_score("", u)
|
| 87 |
+
alignment.append({"target": "", "user": u, "status": status, "similarity": score})
|
| 88 |
+
|
| 89 |
+
return alignment
|
| 90 |
+
|
| 91 |
+
@staticmethod
|
| 92 |
+
def calculate_accuracy(alignment: List[Dict]) -> float:
|
| 93 |
+
if not alignment: return 0.0
|
| 94 |
+
total_score = 0
|
| 95 |
+
valid_items = 0
|
| 96 |
+
for item in alignment:
|
| 97 |
+
status = item.get("status")
|
| 98 |
+
if status == "correct": total_score += 100
|
| 99 |
+
elif status == "similar": total_score += 75
|
| 100 |
+
valid_items += 1
|
| 101 |
+
return round(total_score / valid_items, 1) if valid_items > 0 else 0.0
|
app/utils/template_generator.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import io
|
| 3 |
+
from typing import List
|
| 4 |
+
from app.core.exceptions import AppError
|
| 5 |
+
|
| 6 |
+
class TemplateGenerator:
|
| 7 |
+
|
| 8 |
+
@staticmethod
|
| 9 |
+
def _create_excel_buffer(data: List[List[str]], columns: List[str], instructions: List[str]) -> io.BytesIO:
|
| 10 |
+
try:
|
| 11 |
+
buffer = io.BytesIO()
|
| 12 |
+
with pd.ExcelWriter(buffer, engine='xlsxwriter') as writer:
|
| 13 |
+
# Sheet 1: Template Data
|
| 14 |
+
df = pd.DataFrame(data, columns=columns)
|
| 15 |
+
df.to_excel(writer, sheet_name='Data Template', index=False)
|
| 16 |
+
|
| 17 |
+
# Sheet 2: Instructions
|
| 18 |
+
df_inst = pd.DataFrame({'Instructions': instructions})
|
| 19 |
+
df_inst.to_excel(writer, sheet_name='Instructions', index=False)
|
| 20 |
+
|
| 21 |
+
# Auto-adjust column width
|
| 22 |
+
worksheet = writer.sheets['Data Template']
|
| 23 |
+
for i, col in enumerate(columns):
|
| 24 |
+
worksheet.set_column(i, i, 20)
|
| 25 |
+
|
| 26 |
+
buffer.seek(0)
|
| 27 |
+
return buffer
|
| 28 |
+
except Exception as e:
|
| 29 |
+
raise AppError(status_code=500, detail=f"Failed to generate template: {str(e)}")
|
| 30 |
+
|
| 31 |
+
@staticmethod
|
| 32 |
+
def get_phoneme_word_template() -> io.BytesIO:
|
| 33 |
+
columns = ["kategori", "kata", "fonem", "arti", "definisi"]
|
| 34 |
+
data = [
|
| 35 |
+
['i', 'believe', 'bɪliv', 'percaya', 'Menerima kebenaran.'],
|
| 36 |
+
['p', 'push', 'pʊʃ', 'mendorong', 'Memberi tekanan.']
|
| 37 |
+
]
|
| 38 |
+
instructions = [
|
| 39 |
+
"1. Kategori: Fonem tunggal (contoh: i, p, b)",
|
| 40 |
+
"2. Fonem: Transkripsi IPA",
|
| 41 |
+
"3. Semua kolom wajib diisi"
|
| 42 |
+
]
|
| 43 |
+
return TemplateGenerator._create_excel_buffer(data, columns, instructions)
|
| 44 |
+
|
| 45 |
+
@staticmethod
|
| 46 |
+
def get_phoneme_sentence_template() -> io.BytesIO:
|
| 47 |
+
columns = ["kategori", "kalimat", "fonem"]
|
| 48 |
+
data = [
|
| 49 |
+
['i-ɪ', 'He did see if this big team is really in it.', 'hi dɪd si ɪf ðɪs bɪg tim ɪz rɪəli ɪn ɪt']
|
| 50 |
+
]
|
| 51 |
+
instructions = [
|
| 52 |
+
"1. Kategori: Minimal pairs (contoh: i-ɪ, p-b)",
|
| 53 |
+
"2. Kalimat minimal 10 kata"
|
| 54 |
+
]
|
| 55 |
+
return TemplateGenerator._create_excel_buffer(data, columns, instructions)
|
| 56 |
+
|
| 57 |
+
@staticmethod
|
| 58 |
+
def get_phoneme_exam_template() -> io.BytesIO:
|
| 59 |
+
# Generate dynamic columns for 10 sentences
|
| 60 |
+
columns = ["kategori"]
|
| 61 |
+
for i in range(1, 11):
|
| 62 |
+
columns.extend([f"kalimat_{i}", f"fonem_{i}"])
|
| 63 |
+
|
| 64 |
+
data_row = ["i-ɪ"]
|
| 65 |
+
for _ in range(10):
|
| 66 |
+
data_row.extend(["Example Sentence", "fonem"])
|
| 67 |
+
|
| 68 |
+
data = [data_row]
|
| 69 |
+
instructions = [
|
| 70 |
+
"1. Kategori: Minimal pairs (contoh: i-ɪ)",
|
| 71 |
+
"2. Wajib mengisi 10 kalimat dan 10 fonem lengkap",
|
| 72 |
+
"3. Satu baris = Satu paket ujian"
|
| 73 |
+
]
|
| 74 |
+
return TemplateGenerator._create_excel_buffer(data, columns, instructions)
|
| 75 |
+
|
| 76 |
+
@staticmethod
|
| 77 |
+
def get_talent_template() -> io.BytesIO:
|
| 78 |
+
columns = ["nama", "email", "role", "password"]
|
| 79 |
+
data = [
|
| 80 |
+
['John Doe', 'john@example.com', 'Software Engineer', 'Password123']
|
| 81 |
+
]
|
| 82 |
+
instructions = [
|
| 83 |
+
"1. Email harus unik",
|
| 84 |
+
"2. Password minimal 6 karakter",
|
| 85 |
+
"3. Role opsional (default: talent)"
|
| 86 |
+
]
|
| 87 |
+
return TemplateGenerator._create_excel_buffer(data, columns, instructions)
|