justKevv commited on
Commit
46610a8
·
1 Parent(s): bd79394
Dockerfile CHANGED
@@ -17,4 +17,4 @@ EXPOSE 7860
17
 
18
  # --- CORRECTED COMMAND ---
19
  # This now correctly points to the 'app' variable inside the 'api/index.py' file
20
- CMD ["uvicorn", "api.index:app", "--host", "0.0.0.0", "--port", "7860"]
 
17
 
18
  # --- CORRECTED COMMAND ---
19
  # This now correctly points to the 'app' variable inside the 'api/index.py' file
20
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
api/index.py DELETED
@@ -1,29 +0,0 @@
1
- from fastapi import FastAPI
2
- from fastapi.middleware.cors import CORSMiddleware
3
- # Absolute import from the project root
4
- from app.api.routes.recommendations import router as recommendations_router
5
-
6
- # This file is now the main entry point
7
- app = FastAPI(
8
- title="Student Recommendation API",
9
- description="API for student internship recommendations",
10
- version="1.0.0"
11
- )
12
-
13
- app.add_middleware(
14
- CORSMiddleware,
15
- allow_origins=["*"], # Allow all origins
16
- allow_credentials=True,
17
- allow_methods=["*"], # Allow all methods
18
- allow_headers=["*"], # Allow all headers
19
- )
20
-
21
- @app.get("/", tags=["Root"])
22
- async def read_root():
23
- """A simple endpoint to confirm the API is running."""
24
- return {"message": "Welcome to the Student Recommendation API!"}
25
-
26
- # Include the routes from your recommendations module
27
- app.include_router(recommendations_router, prefix="/api")
28
-
29
- # The Vercel server will discover and run this 'app' object.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/routes/recommendations.py CHANGED
@@ -1,28 +1,15 @@
1
- from fastapi import APIRouter, HTTPException
2
- # --- Use absolute imports from 'app' ---
3
- from app.schemas.recommendation import RecommendationRequest, RecommendationResponse, CategoryResponse
4
- from app.services.ranking import get_ranked_internships, get_category_prediction
5
 
6
  router = APIRouter()
7
 
8
- @router.post("/predict-category", response_model=CategoryResponse)
9
- def predict_category(request: RecommendationRequest):
10
- """Predicts the job category based on the user's profile text."""
11
- try:
12
- predicted_category = get_category_prediction(request.profile_text)
13
- return CategoryResponse(predicted_category=predicted_category)
14
- except Exception as e:
15
- # Log the error for debugging
16
- print(f"Error in /predict-category: {e}")
17
- raise HTTPException(status_code=500, detail="An error occurred during category prediction.")
18
 
19
- @router.post("/rank-internships", response_model=RecommendationResponse)
20
- def rank_internships(request: RecommendationRequest):
21
- """Ranks internships based on user profile and preferences."""
22
- try:
23
- ranked_ids = get_ranked_internships(request)
24
- return RecommendationResponse(ranked_internship_ids=ranked_ids)
25
- except Exception as e:
26
- # Log the error for debugging
27
- print(f"Error in /rank-internships: {e}")
28
- raise HTTPException(status_code=500, detail="An error occurred during internship ranking.")
 
1
+ from fastapi import APIRouter
2
+ from ...schemas.recommendation import ProfileRequest, RecommendationRequest
3
+ from ...services import ranking
 
4
 
5
  router = APIRouter()
6
 
7
+ @router.post("/predict-category", tags=["Predictions"])
8
+ def predict_category(request: ProfileRequest):
9
+ category = ranking.get_category_prediction(request.profile_text)
10
+ return {"predicted_category": category}
 
 
 
 
 
 
11
 
12
+ @router.post("/recommend-internships", tags=["Predictions"])
13
+ def recommend_internships(request: RecommendationRequest):
14
+ ranked_ids = ranking.get_ranked_internships(request)
15
+ return {"recommendations": ranked_ids}
 
 
 
 
 
 
app/core/models.py CHANGED
@@ -1,89 +1,23 @@
1
- # app/core/models.py
2
-
3
  import joblib
4
  from sentence_transformers import SentenceTransformer
5
  import os
6
- import requests
7
- from huggingface_hub import snapshot_download
8
-
9
- TEMP_DIR = "/tmp"
10
-
11
- # --- Initialize models as None ---
12
- # They will be loaded into these global variables later.
13
- tfidf_vectorizer = None
14
- le = None
15
- rf_model = None
16
- sentence_model = None
17
-
18
- # This function remains the same
19
- def download_and_load_pkl_model(model_url, model_filename):
20
- local_path = os.path.join(TEMP_DIR, model_filename)
21
- if not os.path.exists(local_path):
22
- print(f"Downloading model from {model_url} to {local_path}...")
23
- try:
24
- response = requests.get(model_url, stream=True)
25
- response.raise_for_status()
26
- with open(local_path, "wb") as f:
27
- for chunk in response.iter_content(chunk_size=8192):
28
- f.write(chunk)
29
- print("Download complete.")
30
- except requests.exceptions.RequestException as e:
31
- print(f"Failed to download model {model_filename}. Error: {e}")
32
- return None
33
- try:
34
- return joblib.load(local_path)
35
- except Exception as e:
36
- print(f"Failed to load model {local_path}. Error: {e}")
37
- return None
38
-
39
- # This function remains the same
40
- def get_sentence_transformer(model_name='all-MiniLM-L6-v2'):
41
- local_model_path = os.path.join(TEMP_DIR, model_name)
42
- if not os.path.exists(local_model_path):
43
- print(f"Downloading SentenceTransformer model '{model_name}' to {local_model_path}...")
44
- try:
45
- snapshot_download(repo_id=f"sentence-transformers/{model_name}",
46
- local_dir=local_model_path,
47
- local_dir_use_symlinks=False)
48
- print("Download complete.")
49
- except Exception as e:
50
- print(f"Failed to download SentenceTransformer model. Error: {e}")
51
- return None
52
- try:
53
- print(f"Loading SentenceTransformer model from {local_model_path}...")
54
- return SentenceTransformer(local_model_path)
55
- except Exception as e:
56
- print(f"Failed to load SentenceTransformer model from {local_model_path}. Error: {e}")
57
- return None
58
-
59
- # --- NEW LAZY LOADING FUNCTION ---
60
- def load_all_models():
61
- """
62
- This function loads all models into the global variables.
63
- It will only be called when the models are first needed.
64
- """
65
- global tfidf_vectorizer, le, rf_model, sentence_model
66
- print("--- First request received, initiating model loading... ---")
67
 
68
- # Load the classification models
69
- TFIDF_URL = "https://pub-4a389a9b2dc842a2a55678d2db0ec0c6.r2.dev/tfidf_vectorizer.pkl"
70
- LE_URL = "https://pub-4a389a9b2dc842a2a55678d2db0ec0c6.r2.dev/label_encoder.pkl"
71
- RF_MODEL_URL = "https://pub-4a389a9b2dc842a2a55678d2db0ec0c6.r2.dev/random_forest_model.pkl"
72
 
73
- tfidf_vectorizer = download_and_load_pkl_model(TFIDF_URL, "tfidf_vectorizer.pkl")
74
- le = download_and_load_pkl_model(LE_URL, "label_encoder.pkl")
75
- rf_model = download_and_load_pkl_model(RF_MODEL_URL, "random_forest_model.pkl")
 
 
76
 
77
- if all([tfidf_vectorizer, le, rf_model]):
78
- print("Classification models loaded successfully.")
79
- else:
80
- print("One or more classification models failed to load.")
81
 
82
- # Load the sentence transformer
83
- sentence_model = get_sentence_transformer()
84
- if sentence_model:
85
- print("SentenceTransformer model loaded successfully.")
86
- else:
87
- print("SentenceTransformer model failed to load.")
88
 
89
- print("--- Model loading complete. ---")
 
 
 
 
1
  import joblib
2
  from sentence_transformers import SentenceTransformer
3
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
+ MODEL_DIR = "models"
 
 
 
6
 
7
+ try:
8
+ tfidf_vectorizer = joblib.load(os.path.join(MODEL_DIR, "tfidf_vectorizer.pkl"))
9
+ le = joblib.load(os.path.join(MODEL_DIR, "label_encoder.pkl"))
10
+ rf_model = joblib.load(os.path.join(MODEL_DIR, "random_forest_model.pkl"))
11
+ print("Classification models loaded.")
12
 
13
+ sentence_model = SentenceTransformer('all-MiniLM-L6-v2')
14
+ print("SentenceTransformer model loaded.")
 
 
15
 
16
+ except FileNotFoundError as e:
17
+ print(f"MODEL LOADING ERROR: {e}")
18
+ print("Make sure the .pkl files are in the 'models' directory.")
19
+ # In a real production app, you might want the app to exit or handle this more gracefully.
20
+ tfidf_vectorizer, le, rf_model, sentence_model = None, None, None, None
 
21
 
22
+ except Exception as e:
23
+ print(f"An unexpected error occurred during model loading: {e}")
app/main.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from .api.routes import recommendations
3
+
4
+ app = FastAPI(
5
+ title="Student Recommendation API",
6
+ description="An API that uses machine learning to predict job categories and recommend internships.",
7
+ version="1.0.0"
8
+ )
9
+
10
+ app.include_router(recommendations.router, prefix="/api/v1")
11
+
12
+ @app.get("/", tags=["Root"])
13
+ def read_root():
14
+ return {"message": "Welcome to the Student Recommendation API"}
app/services/ranking.py CHANGED
@@ -1,82 +1,121 @@
1
- # app/services/ranking.py
2
-
3
  from geopy.distance import geodesic
4
  import json
5
  import os
6
  from geopy.geocoders import Nominatim
7
 
8
- # --- Use absolute imports from 'app' ---
9
- from app.core.models import load_all_models, tfidf_vectorizer, le, rf_model, sentence_model
10
- from app.schemas.recommendation import RecommendationRequest
11
- from app.utils.text import clean_resume
12
-
13
- print("--- Loading app/services/ranking.py module ---")
14
 
15
- # This geo-caching logic is fine
16
  geolocator = Nominatim(user_agent="student_recommendation_api_v1")
 
17
  GEO_CACHE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "GEO_CACHE.txt")
18
  GEO_CACHE = {}
19
- _geo_cache_loaded = False
20
 
21
  def load_geo_cache():
22
- global GEO_CACHE, _geo_cache_loaded
23
- if _geo_cache_loaded: return
24
  if os.path.exists(GEO_CACHE_FILE):
25
  with open(GEO_CACHE_FILE, "r") as f:
26
- try: GEO_CACHE = json.load(f)
27
- except json.JSONDecodeError: GEO_CACHE = {}
28
- _geo_cache_loaded = True
 
29
 
30
  def save_geo_cache():
31
- with open(GEO_CACHE_FILE, "w") as f: json.dump(GEO_CACHE, f)
 
 
 
32
 
33
  def geo_coords(city_name: str) -> tuple | None:
34
- load_geo_cache()
 
 
 
35
  city_name = city_name.lower().strip()
36
- if city_name in GEO_CACHE: return GEO_CACHE[city_name]
 
37
  try:
 
38
  location = geolocator.geocode(f"{city_name}, Indonesia")
 
39
  if location:
40
  coords = (location.latitude, location.longitude)
41
  GEO_CACHE[city_name] = coords
42
  save_geo_cache()
43
  return coords
44
  else:
 
45
  GEO_CACHE[city_name] = None
46
  save_geo_cache()
47
  return None
48
- except Exception: return None
 
 
49
 
50
  def get_category_prediction(profile_text: str) -> str:
51
- if rf_model is None: load_all_models()
52
  cleaned_text = profile_text.lower()
53
  vectorized_text = tfidf_vectorizer.transform([cleaned_text])
54
  prediction_encoded = rf_model.predict(vectorized_text)[0]
55
  category = le.inverse_transform([prediction_encoded])[0]
56
  return category
57
 
58
- def get_ranked_internships(request: RecommendationRequest) -> list[int]:
59
- if sentence_model is None: load_all_models()
 
60
  profile_text_to_encode = request.profile_text
 
61
  if request.predicted_category:
62
  profile_text_to_encode = f"The user's predicted job category is {request.predicted_category}. Based on that, consider their profile: {request.profile_text}"
 
63
  profile_embedding = sentence_model.encode(profile_text_to_encode)
64
  internship_texts = [internship.internship_text for internship in request.internships]
65
- if not internship_texts: return []
 
 
 
66
  internship_embeddings = sentence_model.encode(internship_texts)
67
  cosine_score = sentence_model.similarity(profile_embedding, internship_embeddings)[0].tolist()
68
- ranked_by_similarity = [{"id": i.id, "similarity_score": s, "location": i.location} for i, s in zip(request.internships, cosine_score)]
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  final_ranked_list = []
70
  user_coords = geo_coords(request.preferred_location)
 
 
 
 
71
  for internship in ranked_by_similarity:
72
  final_score = internship['similarity_score']
 
73
  if user_coords:
74
  internship_coords = geo_coords(internship['location'])
75
  if internship_coords:
76
  distance_km = geodesic(user_coords, internship_coords).kilometers
77
- if distance_km < 1: final_score += 2.0
78
- elif distance_km < 150: final_score += 0.75
 
 
 
79
  internship['final_score'] = final_score
80
  final_ranked_list.append(internship)
 
81
  final_ranked_list.sort(key=lambda x: x['final_score'], reverse=True)
82
- return [item['id'] for item in final_ranked_list]
 
 
 
 
 
 
 
 
1
  from geopy.distance import geodesic
2
  import json
3
  import os
4
  from geopy.geocoders import Nominatim
5
 
6
+ from ..core.models import tfidf_vectorizer, le, rf_model, sentence_model
7
+ from ..schemas.recommendation import RecommendationRequest
8
+ from ..utils.text import clean_resume
 
 
 
9
 
 
10
  geolocator = Nominatim(user_agent="student_recommendation_api_v1")
11
+
12
  GEO_CACHE_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "GEO_CACHE.txt")
13
  GEO_CACHE = {}
 
14
 
15
  def load_geo_cache():
16
+ global GEO_CACHE
 
17
  if os.path.exists(GEO_CACHE_FILE):
18
  with open(GEO_CACHE_FILE, "r") as f:
19
+ try:
20
+ GEO_CACHE = json.load(f)
21
+ except json.JSONDecodeError:
22
+ GEO_CACHE = {}
23
 
24
  def save_geo_cache():
25
+ with open(GEO_CACHE_FILE, "w") as f:
26
+ json.dump(GEO_CACHE, f)
27
+
28
+ load_geo_cache()
29
 
30
  def geo_coords(city_name: str) -> tuple | None:
31
+ """
32
+ Geocodes a city name to (latitude, longitude).
33
+ Uses an in-memory cache to avoid repeated API calls.
34
+ """
35
  city_name = city_name.lower().strip()
36
+ if city_name in GEO_CACHE:
37
+ return GEO_CACHE[city_name]
38
  try:
39
+ print(f"--- Geocoding and caching new city: {city_name} ---")
40
  location = geolocator.geocode(f"{city_name}, Indonesia")
41
+
42
  if location:
43
  coords = (location.latitude, location.longitude)
44
  GEO_CACHE[city_name] = coords
45
  save_geo_cache()
46
  return coords
47
  else:
48
+ print(f"Location not found for {city_name}")
49
  GEO_CACHE[city_name] = None
50
  save_geo_cache()
51
  return None
52
+ except Exception as e:
53
+ print(f"Error geocoding {city_name}: {e}")
54
+ return None
55
 
56
  def get_category_prediction(profile_text: str) -> str:
57
+ """Processes text and predicts the job category."""
58
  cleaned_text = profile_text.lower()
59
  vectorized_text = tfidf_vectorizer.transform([cleaned_text])
60
  prediction_encoded = rf_model.predict(vectorized_text)[0]
61
  category = le.inverse_transform([prediction_encoded])[0]
62
  return category
63
 
64
+ def get_ranked_internships(request: RecommendationRequest) -> list[int]:
65
+ """Performs two-stage ranking with dynamic geocoding."""
66
+
67
  profile_text_to_encode = request.profile_text
68
+
69
  if request.predicted_category:
70
  profile_text_to_encode = f"The user's predicted job category is {request.predicted_category}. Based on that, consider their profile: {request.profile_text}"
71
+
72
  profile_embedding = sentence_model.encode(profile_text_to_encode)
73
  internship_texts = [internship.internship_text for internship in request.internships]
74
+
75
+ if not internship_texts:
76
+ return []
77
+
78
  internship_embeddings = sentence_model.encode(internship_texts)
79
  cosine_score = sentence_model.similarity(profile_embedding, internship_embeddings)[0].tolist()
80
+
81
+ print("--- FastAPI Debugging ---")
82
+ print(f"Received {len(internship_texts)} internships to rank.")
83
+ print(f"Calculated Cosine Scores: {cosine_score}")
84
+ print("--------------------------")
85
+
86
+ ranked_by_similarity = []
87
+ for i, internship in enumerate(request.internships):
88
+ ranked_by_similarity.append({
89
+ "id": internship.id,
90
+ "similarity_score": cosine_score[i],
91
+ "location": internship.location,
92
+ })
93
+
94
  final_ranked_list = []
95
  user_coords = geo_coords(request.preferred_location)
96
+
97
+ print(user_coords, request.preferred_location)
98
+
99
+
100
  for internship in ranked_by_similarity:
101
  final_score = internship['similarity_score']
102
+
103
  if user_coords:
104
  internship_coords = geo_coords(internship['location'])
105
  if internship_coords:
106
  distance_km = geodesic(user_coords, internship_coords).kilometers
107
+ if distance_km < 1:
108
+ final_score += 2.0
109
+ elif distance_km < 150:
110
+ final_score += 0.75
111
+
112
  internship['final_score'] = final_score
113
  final_ranked_list.append(internship)
114
+
115
  final_ranked_list.sort(key=lambda x: x['final_score'], reverse=True)
116
+
117
+ final_ids = [item['id'] for item in final_ranked_list]
118
+
119
+ print(final_ranked_list)
120
+
121
+ return final_ids
models/label_encoder.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8515c4baa66a6e66c054a3bf582d85a45839dddea5f61555786d7e47932901a2
3
+ size 1250
models/random_forest_model.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:301e0b80cb9ad940a705f5999a49029bf08c9830d9c1cb5f81c9affbd0799f9f
3
+ size 1869273
models/tfidf_vectorizer.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e9a901d49e17b88b50a5892a1534ce44a1781b26bd07acbe5313780932bdb518
3
+ size 50112
requirements.txt CHANGED
@@ -1,7 +1,6 @@
1
  # Core FastAPI Framework
2
  fastapi
3
  uvicorn
4
- requests
5
 
6
  # Machine Learning & Data
7
  scikit-learn
@@ -9,7 +8,7 @@ joblib
9
  sentence-transformers
10
  torch
11
  geopy
12
- huggingface-hub
13
  # Pydantic is a dependency of FastAPI, but we list it for clarity
14
  pydantic
15
 
 
1
  # Core FastAPI Framework
2
  fastapi
3
  uvicorn
 
4
 
5
  # Machine Learning & Data
6
  scikit-learn
 
8
  sentence-transformers
9
  torch
10
  geopy
11
+
12
  # Pydantic is a dependency of FastAPI, but we list it for clarity
13
  pydantic
14