bat-6 commited on
Commit
5e95de5
·
1 Parent(s): 809b701

feat: implement similarity engine with embedding generation, hybrid ranking, and explainability modules

Browse files
Data/database/__pycache__/sql_connector.cpython-313.pyc CHANGED
Binary files a/Data/database/__pycache__/sql_connector.cpython-313.pyc and b/Data/database/__pycache__/sql_connector.cpython-313.pyc differ
 
Data/database/sql_connector.py CHANGED
@@ -53,7 +53,7 @@ def load_preprocessed_projects():
53
  try:
54
  query = """
55
  SELECT *
56
- FROM PreProcessed_Projects
57
  """
58
 
59
  with engine.connect() as conn:
 
53
  try:
54
  query = """
55
  SELECT *
56
+ FROM preprocess
57
  """
58
 
59
  with engine.connect() as conn:
Data/processed/preprocessed.csv ADDED
The diff for this file is too large to render. See raw diff
 
models/faiss_index.bin CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:228ea7b7be11e828d482432107d6c3baab192ea7018906f35da2282841f5a628
3
- size 783405
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c18f77517ed17a25d9517bf89cd1393284ea02aa84261759cb305268cc744907
3
+ size 770090
models/metadata.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:534c0c0a94e7082f80d28af3e3cc8228a3749078b95eb161cdc86b26b6afc293
3
- size 786625
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2fc6969f2e7119f7fbb5b1c4c7b6823f628e1a7d892d391c7ea3f965eeedbaee
3
+ size 784587
models/project_embeddings.npy CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:481172fb4b1bcfa7886c170cae254bd7b4af7077265d330826f693fcfcdcb077
3
- size 783488
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1039fecbfb1791061f41d99cc727e93bd8bc0f43bc6272d5a1bb42c603859820
3
+ size 768128
src/similarity_model/__init__.py CHANGED
@@ -38,6 +38,11 @@ from .similarity_engine import (
38
  find_similar_projects
39
  )
40
 
 
 
 
 
41
  from .sync_projects import (
42
  sync_projects
43
  )
 
 
38
  find_similar_projects
39
  )
40
 
41
+ from .explainability import (
42
+ generate_explanation
43
+ )
44
+
45
  from .sync_projects import (
46
  sync_projects
47
  )
48
+
src/similarity_model/__pycache__/__init__.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/__init__.cpython-313.pyc and b/src/similarity_model/__pycache__/__init__.cpython-313.pyc differ
 
src/similarity_model/__pycache__/embedding_engine.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/embedding_engine.cpython-313.pyc and b/src/similarity_model/__pycache__/embedding_engine.cpython-313.pyc differ
 
src/similarity_model/__pycache__/feature_similarity.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/feature_similarity.cpython-313.pyc and b/src/similarity_model/__pycache__/feature_similarity.cpython-313.pyc differ
 
src/similarity_model/__pycache__/hybrid_ranker.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/hybrid_ranker.cpython-313.pyc and b/src/similarity_model/__pycache__/hybrid_ranker.cpython-313.pyc differ
 
src/similarity_model/__pycache__/preprocessing.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/preprocessing.cpython-313.pyc and b/src/similarity_model/__pycache__/preprocessing.cpython-313.pyc differ
 
src/similarity_model/__pycache__/similarity_engine.cpython-313.pyc CHANGED
Binary files a/src/similarity_model/__pycache__/similarity_engine.cpython-313.pyc and b/src/similarity_model/__pycache__/similarity_engine.cpython-313.pyc differ
 
src/similarity_model/embedding_engine.py CHANGED
@@ -65,6 +65,7 @@ class ProjectEmbedder:
65
  ).tolist()
66
 
67
  embeddings = self.generate_embeddings(rich_texts)
 
68
  dim = embeddings.shape[1]
69
 
70
  base_index = faiss.IndexFlatIP(dim)
@@ -79,6 +80,8 @@ class ProjectEmbedder:
79
  path.mkdir(parents=True, exist_ok=True)
80
  faiss.write_index(self.index, str(path / "faiss_index.bin"))
81
  self.metadata.to_parquet(path / "metadata.parquet", index=False)
 
 
82
  logger.info(f"Artifacts saved to {folder}")
83
 
84
  def load_artifacts(self, folder: str = "models"):
 
65
  ).tolist()
66
 
67
  embeddings = self.generate_embeddings(rich_texts)
68
+ self.embeddings = embeddings
69
  dim = embeddings.shape[1]
70
 
71
  base_index = faiss.IndexFlatIP(dim)
 
80
  path.mkdir(parents=True, exist_ok=True)
81
  faiss.write_index(self.index, str(path / "faiss_index.bin"))
82
  self.metadata.to_parquet(path / "metadata.parquet", index=False)
83
+ if hasattr(self, "embeddings"):
84
+ np.save(str(path / "project_embeddings.npy"), self.embeddings)
85
  logger.info(f"Artifacts saved to {folder}")
86
 
87
  def load_artifacts(self, folder: str = "models"):
src/similarity_model/evaluation.py CHANGED
@@ -165,16 +165,22 @@ def final_status(
165
  )
166
 
167
  if final_score >= 0.90:
168
- print("EXCELLENT ✅")
169
 
170
  elif final_score >= 0.75:
171
- print("VERY GOOD ✅")
172
 
173
  elif final_score >= 0.60:
174
- print("GOOD ⚠️")
175
 
176
  else:
177
- print("NEEDS IMPROVEMENT ❌")
178
 
179
  print("Overall Score:", round(final_score, 4))
180
  print("==============================\n")
 
 
 
 
 
 
 
165
  )
166
 
167
  if final_score >= 0.90:
168
+ print("EXCELLENT [OK]")
169
 
170
  elif final_score >= 0.75:
171
+ print("VERY GOOD [OK]")
172
 
173
  elif final_score >= 0.60:
174
+ print("GOOD [WARN]")
175
 
176
  else:
177
+ print("NEEDS IMPROVEMENT [FAIL]")
178
 
179
  print("Overall Score:", round(final_score, 4))
180
  print("==============================\n")
181
+
182
+ if __name__ == "__main__":
183
+ self_score = run_self_test()
184
+ query_score = run_real_queries()
185
+ final_status(self_score, query_score)
186
+
src/similarity_model/explainability.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def generate_explanation(originality_score: float, similarity_results, query_features) -> str:
2
+ explanation = []
3
+ explanation.append(f"Originality Score: {originality_score:.2f}%")
4
+
5
+ if originality_score < 40.0:
6
+ explanation.append("Classification: LOW ORIGINALITY (High Duplicate Risk)\n")
7
+ elif originality_score < 75.0:
8
+ explanation.append("Classification: MEDIUM ORIGINALITY (Moderate Similarity)\n")
9
+ else:
10
+ explanation.append("Classification: HIGH ORIGINALITY (Novel Idea)\n")
11
+
12
+ explanation.append("Top Similar Projects in Database:")
13
+
14
+ # Loop over matched results
15
+ for idx, row in similarity_results.iterrows():
16
+ title = row.get("project_title", "Unknown Title")
17
+ score = row.get("hybrid_score", 0.0) * 100
18
+ explanation.append(f"- \"{title}\" (Similarity Score: {score:.1f}%)")
19
+
20
+ explanation.append("\nConcept Matching Analysis:")
21
+ # Highlight matched features of the best candidate
22
+ if len(similarity_results) > 0:
23
+ best_row = similarity_results.iloc[0]
24
+ matched_feats = best_row.get("matched_features", [])
25
+ unique_feats = best_row.get("unique_query_features", [])
26
+
27
+ explanation.append("Matched Concepts (Shared with database):")
28
+ if matched_feats:
29
+ for item in matched_feats:
30
+ if isinstance(item, dict) and 'feature_a' in item and 'feature_b' in item:
31
+ explanation.append(f" * \"{item['feature_a']}\" matched with \"{item['feature_b']}\"")
32
+ else:
33
+ explanation.append(f" * \"{item}\"")
34
+ else:
35
+ explanation.append(" * No shared concepts found.")
36
+
37
+ explanation.append("\nUnique Concepts (Exclusive to this project):")
38
+ if unique_feats:
39
+ for item in unique_feats:
40
+ explanation.append(f" * \"{item}\"")
41
+ else:
42
+ explanation.append(" * No unique concepts identified.")
43
+
44
+ return "\n".join(explanation)
src/similarity_model/hybrid_ranker.py CHANGED
@@ -40,29 +40,13 @@ def get_dynamic_weights(
40
  """
41
  Adaptive weights depending on feature richness.
42
  Returns (semantic_w, feature_w, coverage_w) — always sum to 1.0
43
-
44
- NOTE: When coverage==0 (DB project has no stored features),
45
- always fall back to the high-semantic-weight path so that
46
- a rich multi-feature query is not penalised vs a sparse one.
47
  """
48
-
49
- # No feature evidence at all — rely on semantic regardless of query richness
50
- # Use 0.50 weight (not 0.70) to compensate for all-mpnet-base-v2's higher
51
- # within-domain baseline scores; prevents same-domain/different-purpose
52
- # projects from scoring too high when no features overlap.
53
- if coverage == 0:
54
- return 0.50, 0.30, 0.20
55
-
56
- # Rich features + high overlap → trust features heavily
57
- if feature_count >= 5 and coverage >= 0.60:
58
- return 0.40, 0.45, 0.15
59
-
60
- # Sparse query features
61
- if feature_count <= 2:
62
- return 0.70, 0.20, 0.10
63
-
64
- # Balanced
65
- return 0.55, 0.35, 0.10
66
 
67
  def compute_hybrid_score(
68
  semantic_score,
@@ -120,6 +104,40 @@ def compute_hybrid_score(
120
 
121
  return round(clamp(score), 4)
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  def compute_originality(
124
  hybrid_score: float,
125
  unique_query_features: int,
@@ -127,21 +145,16 @@ def compute_originality(
127
  ) -> float:
128
  """
129
  Originality Score (0-100).
130
- Base: (1 - hybrid_score) * 100.
131
-
132
- Uniqueness bonus (+up to 10 pts) is only applied when the DB project
133
- actually has stored features (total_query_features > 0 AND there were
134
- real feature matches to compare against). While DB features are empty,
135
- unique_query_features == total_query_features always, making the bonus
136
- a constant +10 noise term — so we skip it until the parquet is rebuilt.
137
  """
138
  hybrid_score = clamp(hybrid_score)
139
-
140
- originality = 100.0 * (1.0 - hybrid_score)
 
 
 
141
 
142
  # Only apply uniqueness bonus when feature comparison was meaningful
143
- # (i.e. the candidate had stored features, so coverage > 0)
144
- # Guarded by unique < total to avoid the always-1.0 ratio when DB is empty
145
  if total_query_features > 0 and unique_query_features < total_query_features:
146
  uniqueness_ratio = unique_query_features / total_query_features
147
  originality = min(100.0, originality + (uniqueness_ratio * 10.0))
 
40
  """
41
  Adaptive weights depending on feature richness.
42
  Returns (semantic_w, feature_w, coverage_w) — always sum to 1.0
 
 
 
 
43
  """
44
+ if feature_count < 5:
45
+ # Sparse Feature Set configuration
46
+ return 0.75, 0.15, 0.10
47
+ else:
48
+ # Rich Feature Set configuration
49
+ return 0.50, 0.35, 0.15
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
  def compute_hybrid_score(
52
  semantic_score,
 
104
 
105
  return round(clamp(score), 4)
106
 
107
+ import numpy as np
108
+ import random
109
+
110
+ _dynamic_baseline_similarity = None
111
+
112
+ def get_baseline_similarity():
113
+ global _dynamic_baseline_similarity
114
+ if _dynamic_baseline_similarity is None:
115
+ try:
116
+ from src.similarity_model.semantic_search import load_embeddings
117
+ embeddings = load_embeddings()
118
+ n = len(embeddings)
119
+ if n > 1:
120
+ # Sample random pairs
121
+ num_samples = min(2000, n * (n - 1) // 2)
122
+ pairs = set()
123
+ while len(pairs) < num_samples:
124
+ i = random.randint(0, n - 1)
125
+ j = random.randint(0, n - 1)
126
+ if i != j:
127
+ pairs.add((min(i, j), max(i, j)))
128
+
129
+ similarities = []
130
+ for i, j in pairs:
131
+ # Dot product since vectors are normalized
132
+ sim = float(np.dot(embeddings[i], embeddings[j]))
133
+ similarities.append(sim)
134
+ _dynamic_baseline_similarity = float(np.median(similarities))
135
+ else:
136
+ _dynamic_baseline_similarity = 0.25
137
+ except Exception:
138
+ _dynamic_baseline_similarity = 0.25
139
+ return _dynamic_baseline_similarity
140
+
141
  def compute_originality(
142
  hybrid_score: float,
143
  unique_query_features: int,
 
145
  ) -> float:
146
  """
147
  Originality Score (0-100).
148
+ Base: (1 - calibrated_similarity) * 100.
 
 
 
 
 
 
149
  """
150
  hybrid_score = clamp(hybrid_score)
151
+ baseline_sim = get_baseline_similarity()
152
+
153
+ # Subtraction and Min-Max scaling
154
+ calibrated_similarity = max(0.0, (hybrid_score - baseline_sim) / (1.0 - baseline_sim))
155
+ originality = 100.0 * (1.0 - calibrated_similarity)
156
 
157
  # Only apply uniqueness bonus when feature comparison was meaningful
 
 
158
  if total_query_features > 0 and unique_query_features < total_query_features:
159
  uniqueness_ratio = unique_query_features / total_query_features
160
  originality = min(100.0, originality + (uniqueness_ratio * 10.0))
src/similarity_model/preprocessing.py CHANGED
@@ -74,19 +74,58 @@ def _get_yake_extractor():
74
  logger.info("Initializing YAKE NLP feature extractor")
75
  return yake.KeywordExtractor(lan="en", n=3, dedupLim=0.9, top=20, features=None)
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def extract_features(text: str) -> list:
78
  """
79
  Extracts detailed, multi-word phrases generated purely by YAKE.
 
80
  """
81
  matched = []
82
  try:
83
  kw_extractor = _get_yake_extractor()
84
  yake_results = kw_extractor.extract_keywords(text)
85
 
 
 
 
86
  for kw, score in yake_results:
87
  kw_clean = str(kw).strip().lower()
88
  if len(kw_clean.split()) > 1 and kw_clean not in matched:
89
- matched.append(kw_clean)
 
 
 
90
 
91
  except Exception as e:
92
  logger.error(f"YAKE extraction failed: {e}")
 
74
  logger.info("Initializing YAKE NLP feature extractor")
75
  return yake.KeywordExtractor(lan="en", n=3, dedupLim=0.9, top=20, features=None)
76
 
77
+ import json
78
+
79
+ _feature_db_frequencies = None
80
+
81
+ def load_feature_frequencies_cache():
82
+ global _feature_db_frequencies
83
+ if _feature_db_frequencies is None:
84
+ try:
85
+ from src.similarity_model.semantic_search import load_metadata
86
+ df = load_metadata()
87
+ from collections import Counter
88
+ counter = Counter()
89
+ total_docs = len(df)
90
+ if total_docs > 0:
91
+ for feats in df["features"]:
92
+ if isinstance(feats, str):
93
+ try:
94
+ feats = json.loads(feats)
95
+ except:
96
+ feats = []
97
+ if isinstance(feats, list):
98
+ seen = set(str(f).strip().lower() for f in feats)
99
+ for f in seen:
100
+ if f:
101
+ counter[f] += 1
102
+ _feature_db_frequencies = {k: v / total_docs for k, v in counter.items()}
103
+ else:
104
+ _feature_db_frequencies = {}
105
+ except Exception:
106
+ _feature_db_frequencies = {}
107
+ return _feature_db_frequencies
108
+
109
  def extract_features(text: str) -> list:
110
  """
111
  Extracts detailed, multi-word phrases generated purely by YAKE.
112
+ Filters out highly generic features appearing in > 15% of indexed projects.
113
  """
114
  matched = []
115
  try:
116
  kw_extractor = _get_yake_extractor()
117
  yake_results = kw_extractor.extract_keywords(text)
118
 
119
+ freq_cache = load_feature_frequencies_cache()
120
+ max_df_threshold = 0.15 # Filter if keyword appears in > 15% of database
121
+
122
  for kw, score in yake_results:
123
  kw_clean = str(kw).strip().lower()
124
  if len(kw_clean.split()) > 1 and kw_clean not in matched:
125
+ # Apply IDF filter check
126
+ doc_freq = freq_cache.get(kw_clean, 0.0)
127
+ if doc_freq <= max_df_threshold:
128
+ matched.append(kw_clean)
129
 
130
  except Exception as e:
131
  logger.error(f"YAKE extraction failed: {e}")
src/similarity_model/similarity_engine.py CHANGED
@@ -389,8 +389,12 @@ def find_similar_projects(
389
  "No strong similar projects found."
390
  }])
391
 
 
 
 
392
  final_df = pd.DataFrame(rows)
393
 
 
394
  final_df = final_df.sort_values(
395
  by=[
396
  "hybrid_score",
@@ -398,15 +402,47 @@ def find_similar_projects(
398
  "semantic_score"
399
  ],
400
  ascending=False
401
- ).head(top_k).reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
 
 
 
403
 
 
404
  final_df.loc[
405
  final_df["hybrid_score"] >= 0.90,
406
  "originality_score"
407
  ] = 0.0
408
 
409
-
410
  final_df["query_features_used"] = [
411
  final_features
412
  ] * len(final_df)
@@ -415,4 +451,11 @@ def find_similar_projects(
415
  query_text
416
  ] * len(final_df)
417
 
 
 
 
 
 
 
418
  return final_df
 
 
389
  "No strong similar projects found."
390
  }])
391
 
392
+ import numpy as np
393
+ from src.similarity_model.explainability import generate_explanation
394
+
395
  final_df = pd.DataFrame(rows)
396
 
397
+ # Sort results
398
  final_df = final_df.sort_values(
399
  by=[
400
  "hybrid_score",
 
402
  "semantic_score"
403
  ],
404
  ascending=False
405
+ ).reset_index(drop=True)
406
+
407
+ # Decaying aggregation over Top-5
408
+ K_val = min(5, len(final_df))
409
+ if K_val > 0:
410
+ s1 = float(final_df.loc[0, "hybrid_score"])
411
+ density_penalty = 0.0
412
+ beta = 0.05
413
+ lam = 0.5
414
+ for i in range(1, K_val):
415
+ si = float(final_df.loc[i, "hybrid_score"])
416
+ density_penalty += np.exp(-lam * i) * si
417
+
418
+ aggregated_score = min(1.0, s1 + beta * density_penalty)
419
+
420
+ # Recalculate originality based on aggregated similarity score
421
+ top_row = final_df.iloc[0]
422
+ unique_q_feats = top_row.get("unique_query_features", [])
423
+ total_q_feats = len(query_project[FEATURE_COL])
424
+
425
+ aggregated_originality = compute_originality(
426
+ hybrid_score=aggregated_score,
427
+ unique_query_features=len(unique_q_feats),
428
+ total_query_features=total_q_feats
429
+ )
430
+ if aggregated_score >= 0.90:
431
+ aggregated_originality = 0.0
432
+
433
+ final_df.loc[0, "originality_score"] = aggregated_originality
434
+ else:
435
+ aggregated_score = 0.0
436
 
437
+ # Retain the top match
438
+ final_df = final_df.head(top_k).copy()
439
 
440
+ # Overwrite originality score for any project with >= 0.90 hybrid score to 0.0
441
  final_df.loc[
442
  final_df["hybrid_score"] >= 0.90,
443
  "originality_score"
444
  ] = 0.0
445
 
 
446
  final_df["query_features_used"] = [
447
  final_features
448
  ] * len(final_df)
 
451
  query_text
452
  ] * len(final_df)
453
 
454
+ # Generate explanation
455
+ if len(final_df) > 0:
456
+ overall_orig = float(final_df.loc[0, "originality_score"])
457
+ explanation_text = generate_explanation(overall_orig, final_df, final_features)
458
+ final_df["explanation"] = explanation_text
459
+
460
  return final_df
461
+
src/similarity_model/sync_preprocess.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import sys
5
+ import pandas as pd
6
+ import numpy as np
7
+ from pathlib import Path
8
+ from sklearn.metrics.pairwise import cosine_similarity
9
+
10
+ # Ensure workspace root is in path
11
+ sys.path.append(str(Path(__file__).resolve().parents[2]))
12
+
13
+ from Data.database.sql_connector import engine
14
+ from src.similarity_model.preprocessing import preprocess_dataset, normalize_text
15
+ from src.similarity_model.semantic_search import load_model
16
+ from src.similarity_model.feature_similarity import load_feature_model, compute_feature_similarity
17
+ from src.similarity_model.hybrid_ranker import compute_hybrid_score, compute_originality
18
+
19
+ logging.basicConfig(
20
+ level=logging.INFO,
21
+ format="%(asctime)s | %(levelname)s | %(message)s"
22
+ )
23
+ logger = logging.getLogger(__name__)
24
+
25
+ def run_sync_preprocess():
26
+ logger.info("Initializing Sync and Preprocess Service...")
27
+
28
+ # 1. Verify DB Connection
29
+ try:
30
+ with engine.connect() as conn:
31
+ logger.info("Database connection verified successfully.")
32
+ except Exception as exc:
33
+ logger.error(f"Unable to connect to the SQL database. Error: {exc}")
34
+ sys.exit(1)
35
+
36
+ # 2. Fetch raw projects
37
+ logger.info("Pulling active records from 'Projects' table...")
38
+ projects_query = """
39
+ SELECT *
40
+ FROM Projects
41
+ WHERE Status IN (
42
+ 'Completed',
43
+ 'UnderReview',
44
+ 'In_Progress'
45
+ )
46
+ """
47
+ with engine.connect() as conn:
48
+ raw_df = pd.read_sql(projects_query, conn)
49
+ logger.info(f"Loaded {len(raw_df)} active projects from 'Projects' table.")
50
+
51
+ # 3. Preprocess dataset
52
+ logger.info("Preprocessing dataset...")
53
+ processed_df = preprocess_dataset(raw_df)
54
+ logger.info(f"Total projects after preprocessing filters: {len(processed_df)}")
55
+
56
+ if len(processed_df) == 0:
57
+ logger.warning("No projects left after preprocessing. Exiting.")
58
+ return
59
+
60
+ # 4. Standarize / clean columns to match structure in the screenshot
61
+ cols_to_keep = [
62
+ "id",
63
+ "submittedat",
64
+ "project_title",
65
+ "studentnames",
66
+ "year",
67
+ "abstract",
68
+ "description",
69
+ "problemstatement",
70
+ "proposedsolution",
71
+ "objectives",
72
+ "full_content",
73
+ "clean_text",
74
+ "word_count",
75
+ "features"
76
+ ]
77
+
78
+ for col in cols_to_keep:
79
+ if col not in processed_df.columns:
80
+ processed_df[col] = ""
81
+
82
+ processed_df = processed_df[cols_to_keep]
83
+
84
+ processed_df = processed_df.rename(
85
+ columns={
86
+ "submittedat": "submitted_at",
87
+ "studentnames": "student_names",
88
+ "problemstatement": "problem_statement",
89
+ "proposedsolution": "proposed_solution"
90
+ }
91
+ )
92
+
93
+ # 5. Calculate Originality for each project
94
+ logger.info("Calculating originality score for each project...")
95
+
96
+ # Load models
97
+ model = load_model()
98
+ feature_model = load_feature_model()
99
+
100
+ # Generate embeddings for all preprocessed projects
101
+ rich_texts = []
102
+ for idx, row in processed_df.iterrows():
103
+ title = str(row["project_title"]).strip()
104
+ abstract = str(row["abstract"]).strip()
105
+ description = str(row["description"]).strip()
106
+ feats = row["features"] if isinstance(row["features"], list) else []
107
+
108
+ raw_text = f"{title}. {abstract}. {description}"
109
+ feature_text = " ".join(feats)
110
+ full_text = normalize_text(f"{raw_text}. {feature_text}")
111
+ rich_texts.append(full_text)
112
+
113
+ logger.info("Encoding projects to vector space...")
114
+ embeddings = model.encode(
115
+ rich_texts,
116
+ convert_to_numpy=True,
117
+ normalize_embeddings=True,
118
+ show_progress_bar=True
119
+ ).astype("float32")
120
+
121
+ # Compute cosine similarity matrix
122
+ logger.info("Computing semantic similarity matrix...")
123
+ sim_matrix = cosine_similarity(embeddings, embeddings)
124
+
125
+ originality_scores = []
126
+
127
+ # Loop through each project to find closest match and compute originality
128
+ for i in range(len(processed_df)):
129
+ current_project = processed_df.iloc[i]
130
+ current_features = current_project["features"] if isinstance(current_project["features"], list) else []
131
+
132
+ # Get semantic similarity scores against other projects
133
+ scores = sim_matrix[i].copy()
134
+ scores[i] = -1.0 # Exclude self-matching
135
+
136
+ # Sort and pick top 50 semantic matches as candidates
137
+ top_indices = np.argsort(scores)[::-1][:50]
138
+
139
+ max_hybrid_score = 0.0
140
+ best_candidate_features = []
141
+ best_candidate_idx = -1
142
+
143
+ for idx in top_indices:
144
+ candidate_project = processed_df.iloc[idx]
145
+ sem_score = float(scores[idx])
146
+
147
+ candidate_features = candidate_project["features"] if isinstance(candidate_project["features"], list) else []
148
+
149
+ # Compute feature similarity
150
+ feat_result = compute_feature_similarity(
151
+ current_features,
152
+ candidate_features,
153
+ model=feature_model
154
+ )
155
+
156
+ feature_score = feat_result["score"]
157
+ coverage = feat_result["coverage"]
158
+
159
+ query_feature_count = len(current_features)
160
+ unique_query_count = len(feat_result["unique_a"])
161
+
162
+ # Compute hybrid score
163
+ hybrid_score = compute_hybrid_score(
164
+ semantic_score=sem_score,
165
+ feature_score=feature_score,
166
+ coverage=coverage,
167
+ feature_count=query_feature_count,
168
+ unique_query_count=unique_query_count
169
+ )
170
+
171
+ # Direct copy-paste detection
172
+ query_desc = (str(current_project.get("abstract", "")) + " " + str(current_project.get("description", ""))).strip()
173
+ candidate_desc = (str(candidate_project.get("abstract", "")) + " " + str(candidate_project.get("description", ""))).strip()
174
+
175
+ words_q = set(normalize_text(query_desc).split())
176
+ words_c = set(normalize_text(candidate_desc).split())
177
+
178
+ jaccard_overlap = 0.0
179
+ if words_q and words_c:
180
+ jaccard_overlap = len(words_q.intersection(words_c)) / len(words_q.union(words_c))
181
+
182
+ if jaccard_overlap >= 0.60:
183
+ hybrid_score = 0.95
184
+
185
+ if hybrid_score > max_hybrid_score:
186
+ max_hybrid_score = hybrid_score
187
+ best_candidate_features = candidate_features
188
+ best_candidate_idx = idx
189
+
190
+ # Calculate originality based on the worst-case (highest similarity) match
191
+ if best_candidate_idx != -1 and len(current_features) > 0:
192
+ # Recompute feature similarity for the best candidate to get unique_a count
193
+ feat_result = compute_feature_similarity(
194
+ current_features,
195
+ best_candidate_features,
196
+ model=feature_model
197
+ )
198
+ unique_query_count = len(feat_result["unique_a"])
199
+ orig_score = compute_originality(
200
+ hybrid_score=max_hybrid_score,
201
+ unique_query_features=unique_query_count,
202
+ total_query_features=len(current_features)
203
+ )
204
+ else:
205
+ orig_score = compute_originality(
206
+ hybrid_score=max_hybrid_score,
207
+ unique_query_features=0,
208
+ total_query_features=0
209
+ )
210
+
211
+ originality_scores.append(orig_score)
212
+
213
+ processed_df["originality"] = originality_scores
214
+ logger.info("Finished calculating originality scores.")
215
+
216
+ # 6. Save locally to preprocessed.csv in Data/processed/
217
+ local_dir = Path("Data") / "processed"
218
+ local_dir.mkdir(parents=True, exist_ok=True)
219
+ local_path = local_dir / "preprocessed.csv"
220
+
221
+ # Keep features as readable lists for the CSV file
222
+ csv_df = processed_df.copy()
223
+ csv_df.to_csv(local_path, index=False)
224
+ logger.info(f"Successfully saved preprocessed projects locally to: {local_path}")
225
+
226
+ # 7. Convert features list to JSON string for database push
227
+ db_df = processed_df.copy()
228
+ db_df["features"] = db_df["features"].apply(json.dumps)
229
+
230
+ # 8. Push to table 'preprocess' in database
231
+ logger.info("Uploading preprocessed records to database table 'preprocess'...")
232
+ try:
233
+ with engine.begin() as conn:
234
+ # Drop/replace table if exists to write the new preprocessed structure
235
+ db_df.to_sql(
236
+ "preprocess",
237
+ conn,
238
+ if_exists="replace",
239
+ index=False
240
+ )
241
+ logger.info("Successfully pushed all preprocessed projects to database table 'preprocess'.")
242
+ except Exception as exc:
243
+ logger.error(f"Failed to push table to database. Error: {exc}")
244
+ sys.exit(1)
245
+
246
+ if __name__ == "__main__":
247
+ run_sync_preprocess()