vithariumz commited on
Commit
52f5a2a
·
1 Parent(s): 40112ac

Deploy: Initial Backend Release v1.0

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +6 -0
  2. Dockerfile +30 -0
  3. app/api/deps.py +87 -0
  4. app/api/v1/endpoints/auth.py +40 -0
  5. app/api/v1/endpoints/conversation.py +48 -0
  6. app/api/v1/endpoints/dashboard.py +82 -0
  7. app/api/v1/endpoints/exam.py +55 -0
  8. app/api/v1/endpoints/history.py +68 -0
  9. app/api/v1/endpoints/interview_flow.py +63 -0
  10. app/api/v1/endpoints/material.py +407 -0
  11. app/api/v1/endpoints/mobile_profile.py +17 -0
  12. app/api/v1/endpoints/phoneme.py +104 -0
  13. app/api/v1/endpoints/pretest.py +76 -0
  14. app/api/v1/endpoints/talents.py +130 -0
  15. app/api/v1/endpoints/transcribe.py +12 -0
  16. app/core/config.py +76 -0
  17. app/core/database.py +31 -0
  18. app/core/exceptions.py +33 -0
  19. app/main.py +56 -0
  20. app/models/models.py +123 -0
  21. app/repositories/base.py +39 -0
  22. app/repositories/dashboard_repository.py +167 -0
  23. app/repositories/exam_repository.py +84 -0
  24. app/repositories/history_repository.py +104 -0
  25. app/repositories/material_repository.py +278 -0
  26. app/repositories/score_repository.py +49 -0
  27. app/repositories/talent_repository.py +97 -0
  28. app/schemas/auth.py +17 -0
  29. app/schemas/conversation.py +33 -0
  30. app/schemas/dashboard.py +67 -0
  31. app/schemas/exam.py +20 -0
  32. app/schemas/material.py +57 -0
  33. app/schemas/phoneme.py +16 -0
  34. app/schemas/response.py +9 -0
  35. app/schemas/talent.py +51 -0
  36. app/seeder.py +63 -0
  37. app/services/audio_service.py +100 -0
  38. app/services/auth_service.py +94 -0
  39. app/services/conversation_service.py +68 -0
  40. app/services/dashboard_service.py +151 -0
  41. app/services/exam_service.py +106 -0
  42. app/services/interview_service.py +73 -0
  43. app/services/llm_service.py +146 -0
  44. app/services/material_service.py +235 -0
  45. app/services/phoneme_service.py +122 -0
  46. app/services/profile_service.py +69 -0
  47. app/services/talent_service.py +165 -0
  48. app/utils/calculation_utils.py +25 -0
  49. app/utils/phoneme_utils.py +101 -0
  50. 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)