| import re
|
| import ast
|
| import logging
|
| from pathlib import Path
|
| from functools import lru_cache
|
|
|
| import numpy as np
|
| import pandas as pd
|
| import faiss
|
| from sentence_transformers import SentenceTransformer
|
|
|
| from Data.database.sql_connector import (
|
| load_preprocessed_projects
|
| )
|
|
|
| logging.basicConfig(
|
| level=logging.INFO,
|
| format="%(asctime)s | %(levelname)s | %(message)s"
|
| )
|
| logger = logging.getLogger(__name__)
|
|
|
| DEFAULT_MODEL = "all-mpnet-base-v2"
|
|
|
| TITLE_COL = "project_title"
|
| TECH_COL = "technologies"
|
|
|
| _PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
|
| INDEX_PATH = _PROJECT_ROOT / "models" / "faiss_index.bin"
|
| META_PATH = _PROJECT_ROOT / "models" / "metadata.parquet"
|
| EMBED_PATH = _PROJECT_ROOT / "models" / "project_embeddings.npy"
|
|
|
| TOP_K = 20
|
| MIN_SCORE = 0.35
|
|
|
| def normalize_text(text: str) -> str:
|
| """
|
| Normalize user query to match preprocessing style.
|
| """
|
| if pd.isna(text):
|
| return ""
|
|
|
| text = str(text).strip().lower()
|
|
|
| text = re.sub(r"http\S+|www\S+|\S+@\S+", " ", text)
|
| text = re.sub(r"[^a-z0-9\s\+\#\./\-]", " ", text)
|
| text = re.sub(r"\s+", " ", text).strip()
|
|
|
| return text
|
|
|
| def tokenize(text: str) -> set:
|
| """
|
| Tokenize normalized text.
|
| """
|
| return set(normalize_text(text).split())
|
|
|
| import os
|
|
|
| _cached_faiss_index = None
|
| _cached_faiss_index_mtime = None
|
| _cached_metadata = None
|
| _cached_metadata_mtime = None
|
| _cached_embeddings = None
|
| _cached_embeddings_mtime = None
|
|
|
| @lru_cache(maxsize=1)
|
| def load_model():
|
| logger.info(f"Loading model: {DEFAULT_MODEL}")
|
| return SentenceTransformer(DEFAULT_MODEL)
|
|
|
| def load_faiss_index():
|
| global _cached_faiss_index, _cached_faiss_index_mtime
|
| if not INDEX_PATH.exists():
|
| raise FileNotFoundError("FAISS index not found.")
|
|
|
| mtime = os.path.getmtime(INDEX_PATH)
|
| if _cached_faiss_index is None or _cached_faiss_index_mtime != mtime:
|
| logger.info(f"Loading FAISS index from {INDEX_PATH} (mtime: {mtime})...")
|
| _cached_faiss_index = faiss.read_index(str(INDEX_PATH))
|
| _cached_faiss_index_mtime = mtime
|
| return _cached_faiss_index
|
|
|
| def load_metadata():
|
| global _cached_metadata, _cached_metadata_mtime
|
| if not INDEX_PATH.exists():
|
| raise FileNotFoundError("FAISS index not found for metadata alignment.")
|
|
|
| mtime = os.path.getmtime(INDEX_PATH)
|
| if _cached_metadata is None or _cached_metadata_mtime != mtime:
|
| if META_PATH.exists():
|
| logger.info(f"Loading metadata from local parquet {META_PATH} (syncing with FAISS index mtime: {mtime})...")
|
| try:
|
| df = pd.read_parquet(str(META_PATH))
|
| if "features" in df.columns:
|
| import json
|
| def parse_features(x):
|
| if not isinstance(x, str):
|
| return x
|
| try:
|
| x = json.loads(x)
|
| if isinstance(x, str):
|
| x = json.loads(x)
|
| return x
|
| except Exception:
|
| return []
|
| df["features"] = df["features"].apply(parse_features)
|
| _cached_metadata = df.reset_index(drop=True)
|
| except Exception as e:
|
| logger.warning(f"Failed to read local metadata.parquet: {e}. Falling back to database query...")
|
| df = load_preprocessed_projects()
|
| _cached_metadata = df.reset_index(drop=True)
|
| else:
|
| logger.info(f"Loading metadata from Azure SQL (syncing with FAISS index mtime: {mtime})...")
|
| df = load_preprocessed_projects()
|
| _cached_metadata = df.reset_index(drop=True)
|
| _cached_metadata_mtime = mtime
|
| return _cached_metadata
|
|
|
| def load_embeddings():
|
| global _cached_embeddings, _cached_embeddings_mtime
|
| if not EMBED_PATH.exists():
|
| raise FileNotFoundError("Embeddings not found.")
|
|
|
| mtime = os.path.getmtime(EMBED_PATH)
|
| if _cached_embeddings is None or _cached_embeddings_mtime != mtime:
|
| logger.info(f"Loading embeddings from {EMBED_PATH} (mtime: {mtime})...")
|
| _cached_embeddings = np.load(str(EMBED_PATH))
|
| _cached_embeddings_mtime = mtime
|
| return _cached_embeddings
|
|
|
| def build_results(
|
| df: pd.DataFrame,
|
| ids,
|
| scores,
|
| query_text: str = "",
|
| min_score: float = MIN_SCORE
|
| ) -> pd.DataFrame:
|
|
|
| rows = []
|
|
|
| query_words = tokenize(query_text)
|
|
|
| for idx, score in zip(ids, scores):
|
|
|
| if idx == -1:
|
| continue
|
|
|
| row = df.loc[idx]
|
|
|
| final_score = float(score)
|
|
|
| if query_words:
|
|
|
| title_words = tokenize(row[TITLE_COL])
|
|
|
| tech_words = tokenize(
|
| row.get(TECH_COL, "")
|
| )
|
|
|
| overlap = len(query_words & title_words)
|
| overlap += len(query_words & tech_words)
|
|
|
| if overlap > 0:
|
| final_score += 0.02 * overlap
|
|
|
| final_score = min(final_score, 1.0)
|
|
|
| if final_score < min_score:
|
| continue
|
|
|
| rows.append({
|
| "project_id": int(idx),
|
| "project_title": row[TITLE_COL],
|
| "technologies": row.get(TECH_COL, ""),
|
| "score": round(final_score, 4)
|
| })
|
|
|
| if not rows:
|
| return pd.DataFrame([{
|
| "message": "No similar projects found.",
|
| "score": 0
|
| }])
|
|
|
| return (
|
| pd.DataFrame(rows)
|
| .sort_values("score", ascending=False)
|
| .reset_index(drop=True)
|
| )
|
|
|
| def search_by_text(
|
| query_text: str,
|
| k: int = TOP_K,
|
| min_score: float = MIN_SCORE
|
| ) -> pd.DataFrame:
|
|
|
| model = load_model()
|
| index = load_faiss_index()
|
| df = load_metadata()
|
|
|
| query_clean = normalize_text(query_text)
|
|
|
| query_vec = model.encode(
|
| [query_clean],
|
| convert_to_numpy=True,
|
| normalize_embeddings=True
|
| ).astype("float32")
|
|
|
| scores, ids = index.search(query_vec, k)
|
|
|
| return build_results(
|
| df=df,
|
| ids=ids[0],
|
| scores=scores[0],
|
| query_text=query_clean,
|
| min_score=min_score
|
| )
|
|
|
| def search_by_project_id(
|
| project_id: int,
|
| k: int = TOP_K,
|
| min_score: float = MIN_SCORE,
|
| exclude_self: bool = True
|
| ) -> pd.DataFrame:
|
|
|
| df = load_metadata()
|
| index = load_faiss_index()
|
| embeddings = load_embeddings()
|
|
|
| if project_id < 0 or project_id >= len(df):
|
| raise IndexError("Project ID out of range.")
|
|
|
| query_vec = embeddings[
|
| project_id
|
| ].reshape(1, -1).astype("float32")
|
|
|
| extra_k = k + 1 if exclude_self else k
|
|
|
| scores, ids = index.search(
|
| query_vec,
|
| extra_k
|
| )
|
|
|
| rows = []
|
|
|
| for idx, score in zip(ids[0], scores[0]):
|
|
|
| if idx == -1:
|
| continue
|
|
|
| if exclude_self and idx == project_id:
|
| continue
|
|
|
| final_score = min(float(score), 1.0)
|
|
|
| if final_score < min_score:
|
| continue
|
|
|
| row = df.loc[idx]
|
|
|
| rows.append({
|
| "project_id": int(idx),
|
| "project_title": row[TITLE_COL],
|
| "technologies": row.get(TECH_COL, ""),
|
| "score": round(final_score, 4)
|
| })
|
|
|
| if len(rows) == k:
|
| break
|
|
|
| if not rows:
|
| return pd.DataFrame([{
|
| "message": "No similar projects found.",
|
| "score": 0
|
| }])
|
|
|
| return pd.DataFrame(rows)
|
|
|
| def compare_two_ideas(
|
| text_a: str,
|
| text_b: str
|
| ) -> float:
|
|
|
| model = load_model()
|
|
|
| vecs = model.encode(
|
| [
|
| normalize_text(text_a),
|
| normalize_text(text_b)
|
| ],
|
| convert_to_numpy=True,
|
| normalize_embeddings=True
|
| ).astype("float32")
|
|
|
| score = float(np.dot(vecs[0], vecs[1]))
|
|
|
| return round(score, 4)
|
|
|