Spaces:
Running
Running
| """ | |
| Robust matrimonial recommendation engine. | |
| Pipeline | |
| -------- | |
| 1. Hard filters – eliminate incompatible candidates entirely | |
| 2. Soft scoring – rank surviving candidates by weighted similarity | |
| 3. Return top-N – with full score breakdown for transparency / debugging | |
| Hard filter rules (all must pass): | |
| • opposite_gender | |
| • is_active | |
| • not self | |
| • religion – exact match | |
| • marital_status – grouped match (see MARITAL_GROUPS) | |
| Soft score dimensions (weights sum to 1.0): | |
| • age_compatibility 0.20 – Gaussian, peaks at ideal age gap | |
| • personality_traits 0.25 – cosine similarity on sentence embeddings | |
| • partner_criteria 0.25 – bidirectional keyword + embedding match | |
| • hobbies 0.15 – cosine similarity | |
| • categorical_bonus 0.15 – maslak, country, qualification | |
| """ | |
| import os | |
| import math | |
| import logging | |
| import httpx | |
| import numpy as np | |
| from sklearn.metrics.pairwise import cosine_similarity | |
| from sklearn.feature_extraction.text import TfidfVectorizer | |
| logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # Configuration | |
| # --------------------------------------------------------------------------- | |
| SUPABASE_URL = os.getenv( | |
| "SUPABASE_URL", "https://nquhiryqtbrtpauuxmsc.supabase.co" | |
| ) | |
| SERVICE_KEY = os.getenv( | |
| "SUPABASE_SERVICE_KEY", | |
| "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5xdWhpcnlxdGJydHBhdXV4bXNjIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3NjA4MzcxMCwiZXhwIjoyMDkxNjU5NzEwfQ.q7ysbaSfMv15g7PyJRLMwnRpEyia5d_ol3iLZn0Xayo", | |
| ) | |
| HEADERS = {"apikey": SERVICE_KEY, "Content-Type": "application/json"} | |
| TOP_N = 20 # maximum recommendations returned | |
| AGE_IDEAL_GAP = 3 # years – peak of the Gaussian for age compatibility | |
| AGE_SIGMA = 7 # standard deviation in years; larger = more forgiving | |
| # Soft-score weights (must sum to 1.0) | |
| WEIGHTS = { | |
| "age_compatibility": 0.20, | |
| "personality_traits": 0.25, | |
| "partner_criteria": 0.25, | |
| "hobbies": 0.15, | |
| "categorical_bonus": 0.15, | |
| } | |
| assert abs(sum(WEIGHTS.values()) - 1.0) < 1e-6, "Weights must sum to 1.0" | |
| # Marital status groups: candidates in the same group may match each other. | |
| # Adjust to your product rules. | |
| MARITAL_GROUPS = { | |
| "single": {"single"}, | |
| "divorced": {"divorced", "widowed"}, # divorced ↔ widowed allowed | |
| "widowed": {"divorced", "widowed"}, | |
| "separated":{"separated"}, | |
| } | |
| # Personality / trait keywords used to expand text matching | |
| TRAIT_SYNONYMS = { | |
| "caring": ["caring", "care", "compassionate", "nurturing", "kind"], | |
| "honest": ["honest", "truthful", "sincere", "transparent"], | |
| "religious": ["religious", "practicing", "devout", "practicing muslim", | |
| "practicing christian", "allah", "god", "faith", "deen"], | |
| "family-oriented": ["family", "family-oriented", "family oriented", | |
| "home", "homemaker", "domestic"], | |
| "ambitious": ["ambitious", "driven", "goal-oriented", "career", "successful"], | |
| "respectful": ["respectful", "respect", "humble", "obedient"], | |
| "educated": ["educated", "degree", "professional", "literate"], | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def safe_str(val) -> str: | |
| """Normalise any DB value to a lowercase string.""" | |
| if val is None: | |
| return "" | |
| if isinstance(val, list): | |
| return " ".join(str(v) for v in val if v) | |
| return str(val).strip().lower() | |
| def expand_traits(text: str) -> str: | |
| """ | |
| Expand trait keywords so that 'caring' also pulls in 'compassionate', | |
| 'nurturing', etc. This improves recall when users describe traits | |
| differently. | |
| """ | |
| words = text.lower().split() | |
| extras = [] | |
| for word in words: | |
| for canonical, synonyms in TRAIT_SYNONYMS.items(): | |
| if word in synonyms or canonical in text.lower(): | |
| extras.extend(synonyms) | |
| break | |
| return text + " " + " ".join(extras) | |
| def text_similarity(text_a: str, text_b: str) -> float: | |
| """ | |
| TF-IDF cosine similarity between two text blobs. | |
| Returns a value in [0, 1]. | |
| """ | |
| if not text_a.strip() or not text_b.strip(): | |
| return 0.0 | |
| try: | |
| vect = TfidfVectorizer(stop_words="english", min_df=1) | |
| matrix = vect.fit_transform([text_a, text_b]) | |
| score = cosine_similarity(matrix[0:1], matrix[1:2])[0][0] | |
| return float(np.clip(score, 0.0, 1.0)) | |
| except Exception: | |
| return 0.0 | |
| def age_score(user_age: int, candidate_age: int, gender: str) -> float: | |
| """ | |
| Gaussian age-compatibility score. | |
| Convention (adjustable): | |
| • Male users prefer slightly younger females → ideal gap = +AGE_IDEAL_GAP | |
| • Female users prefer slightly older males → ideal gap = -AGE_IDEAL_GAP | |
| Gap is defined as (candidate_age – user_age). | |
| """ | |
| ideal_gap = AGE_IDEAL_GAP if gender == "male" else -AGE_IDEAL_GAP | |
| gap = candidate_age - user_age - ideal_gap | |
| return float(math.exp(-(gap ** 2) / (2 * AGE_SIGMA ** 2))) | |
| def marital_status_compatible(user_status: str, candidate_status: str) -> bool: | |
| """ | |
| Returns True if the two marital statuses are compatible according to | |
| MARITAL_GROUPS. Falls back to exact match for unrecognised values. | |
| """ | |
| us = user_status.strip().lower() | |
| cs = candidate_status.strip().lower() | |
| if not us or not cs: | |
| return False # missing data → reject (safe default) | |
| allowed = MARITAL_GROUPS.get(us) | |
| if allowed is None: | |
| return us == cs # unknown status: require exact match | |
| return cs in allowed | |
| def religion_compatible(user_religion: str, candidate_religion: str) -> bool: | |
| """Strict exact match on religion (case-insensitive).""" | |
| ur = user_religion.strip().lower() | |
| cr = candidate_religion.strip().lower() | |
| if not ur or not cr: | |
| return False # missing religion → reject | |
| return ur == cr | |
| def categorical_bonus(user: dict, candidate: dict) -> float: | |
| """ | |
| Small score boost for shared optional attributes: | |
| maslak, country, qualification level. | |
| Score is in [0, 1]. | |
| """ | |
| matches = 0 | |
| total = 0 | |
| for key in ["maslak", "country", "qualification"]: | |
| u = safe_str(user.get(key)) | |
| c = safe_str(candidate.get(key)) | |
| if u and c: | |
| total += 1 | |
| if u == c: | |
| matches += 1 | |
| return matches / total if total > 0 else 0.5 # neutral when no data | |
| def bidirectional_criteria_score(user: dict, candidate: dict) -> float: | |
| """ | |
| Score how well the user meets the candidate's criteria AND | |
| how well the candidate meets the user's criteria. | |
| Returns the average of both directions so a one-sided mismatch | |
| still reduces the score. | |
| """ | |
| user_criteria = expand_traits(safe_str(user.get("preferred_partner_criteria"))) | |
| cand_criteria = expand_traits(safe_str(candidate.get("preferred_partner_criteria"))) | |
| # Build text descriptions of each person for comparison | |
| user_desc = expand_traits( | |
| safe_str(user.get("personality_traits")) + " " + | |
| safe_str(user.get("hobbies")) | |
| ) | |
| cand_desc = expand_traits( | |
| safe_str(candidate.get("personality_traits")) + " " + | |
| safe_str(candidate.get("hobbies")) | |
| ) | |
| # user → candidate: does candidate match user's criteria? | |
| u2c = text_similarity(user_criteria, cand_desc) if user_criteria else 0.5 | |
| # candidate → user: does user match candidate's criteria? | |
| c2u = text_similarity(cand_criteria, user_desc) if cand_criteria else 0.5 | |
| return (u2c + c2u) / 2.0 | |
| # --------------------------------------------------------------------------- | |
| # Supabase helpers | |
| # --------------------------------------------------------------------------- | |
| def fetch_all_active_profiles() -> list[dict]: | |
| """Page through all active profiles (100 per request).""" | |
| profiles = [] | |
| offset = 0 | |
| while True: | |
| resp = httpx.get( | |
| f"{SUPABASE_URL}/rest/v1/profiles", | |
| headers=HEADERS, | |
| params={ | |
| "select": "*", | |
| "is_active": "eq.true", | |
| "limit": 100, | |
| "offset": offset, | |
| }, | |
| timeout=15, | |
| ) | |
| resp.raise_for_status() | |
| batch = resp.json() | |
| if not batch: | |
| break | |
| profiles.extend(batch) | |
| offset += 100 | |
| if len(batch) < 100: | |
| break | |
| logger.info("Fetched %d active profiles", len(profiles)) | |
| return profiles | |
| def fetch_profile(user_id: str) -> dict | None: | |
| """Return a single profile or None.""" | |
| resp = httpx.get( | |
| f"{SUPABASE_URL}/rest/v1/profiles", | |
| headers=HEADERS, | |
| params={"id": f"eq.{user_id}", "select": "*"}, | |
| timeout=10, | |
| ) | |
| resp.raise_for_status() | |
| data = resp.json() | |
| return data[0] if data else None | |
| # --------------------------------------------------------------------------- | |
| # Core recommendation logic | |
| # --------------------------------------------------------------------------- | |
| def score_candidate(user: dict, candidate: dict) -> dict: | |
| """ | |
| Compute a detailed score breakdown for one candidate. | |
| Returns a dict with individual dimension scores and a composite. | |
| """ | |
| user_age = int(user.get("age") or 25) | |
| cand_age = int(candidate.get("age") or 25) | |
| user_gender = safe_str(user.get("gender")) | |
| # ── Dimension scores ────────────────────────────────────────────────── | |
| age_compat = age_score(user_age, cand_age, user_gender) | |
| user_traits = expand_traits(safe_str(user.get("personality_traits"))) | |
| cand_traits = expand_traits(safe_str(candidate.get("personality_traits"))) | |
| trait_sim = text_similarity(user_traits, cand_traits) | |
| criteria_sim = bidirectional_criteria_score(user, candidate) | |
| user_hobbies = safe_str(user.get("hobbies")) | |
| cand_hobbies = safe_str(candidate.get("hobbies")) | |
| hobby_sim = text_similarity(user_hobbies, cand_hobbies) | |
| cat_bonus = categorical_bonus(user, candidate) | |
| # ── Weighted composite ──────────────────────────────────────────────── | |
| composite = ( | |
| WEIGHTS["age_compatibility"] * age_compat + | |
| WEIGHTS["personality_traits"] * trait_sim + | |
| WEIGHTS["partner_criteria"] * criteria_sim + | |
| WEIGHTS["hobbies"] * hobby_sim + | |
| WEIGHTS["categorical_bonus"] * cat_bonus | |
| ) | |
| return { | |
| "candidate_id": candidate["id"], | |
| "candidate_name": candidate.get("full_name", "Unknown"), | |
| "age": cand_age, | |
| "religion": candidate.get("religion"), | |
| "marital_status": candidate.get("marital_status"), | |
| "scores": { | |
| "age_compatibility": round(age_compat, 4), | |
| "personality_traits": round(trait_sim, 4), | |
| "partner_criteria": round(criteria_sim, 4), | |
| "hobbies": round(hobby_sim, 4), | |
| "categorical_bonus": round(cat_bonus, 4), | |
| }, | |
| "composite_score": round(composite, 4), | |
| } | |
| def recommend(user_id: str, all_profiles: list[dict] | None = None) -> list[dict]: | |
| """ | |
| Return up to TOP_N recommendations for user_id. | |
| Parameters | |
| ---------- | |
| user_id : the requester's profile ID | |
| all_profiles : pre-fetched profiles (optional, for caching) | |
| Returns | |
| ------- | |
| List of score dicts sorted by composite_score descending. | |
| """ | |
| # 1. Load user | |
| user = fetch_profile(user_id) | |
| if not user: | |
| raise ValueError(f"User {user_id!r} not found") | |
| user_gender = safe_str(user.get("gender")) | |
| user_religion = safe_str(user.get("religion")) | |
| user_marital = safe_str(user.get("marital_status")) | |
| if user_gender not in ("male", "female"): | |
| raise ValueError(f"Invalid gender for user {user_id!r}: {user_gender!r}") | |
| opposite_gender = "female" if user_gender == "male" else "male" | |
| # 2. Load pool | |
| profiles = all_profiles if all_profiles is not None else fetch_all_active_profiles() | |
| # 3. Hard filters | |
| candidates = [] | |
| reject_counts = { | |
| "self": 0, | |
| "gender": 0, | |
| "inactive": 0, | |
| "religion": 0, | |
| "marital_status": 0, | |
| } | |
| for profile in profiles: | |
| pid = profile.get("id") | |
| if pid == user_id: | |
| reject_counts["self"] += 1 | |
| continue | |
| if safe_str(profile.get("gender")) != opposite_gender: | |
| reject_counts["gender"] += 1 | |
| continue | |
| if not profile.get("is_active"): | |
| reject_counts["inactive"] += 1 | |
| continue | |
| if not religion_compatible(user_religion, safe_str(profile.get("religion"))): | |
| reject_counts["religion"] += 1 | |
| continue | |
| if not marital_status_compatible(user_marital, safe_str(profile.get("marital_status"))): | |
| reject_counts["marital_status"] += 1 | |
| continue | |
| candidates.append(profile) | |
| logger.info( | |
| "Hard filter summary — total=%d passed=%d rejected: %s", | |
| len(profiles), len(candidates), reject_counts, | |
| ) | |
| if not candidates: | |
| logger.warning("No candidates survived hard filters for user %s", user_id) | |
| return [] | |
| # 4. Soft scoring | |
| scored = [score_candidate(user, c) for c in candidates] | |
| # 5. Rank and return top-N | |
| scored.sort(key=lambda x: x["composite_score"], reverse=True) | |
| results = scored[:TOP_N] | |
| logger.info( | |
| "Top-%d recommendations for %s | best=%.3f worst=%.3f", | |
| len(results), user_id, | |
| results[0]["composite_score"] if results else 0, | |
| results[-1]["composite_score"] if results else 0, | |
| ) | |
| return results | |
| # --------------------------------------------------------------------------- | |
| # Debug / test runner | |
| # --------------------------------------------------------------------------- | |
| async def test_recommend(): | |
| """Step-by-step diagnostic that mirrors the original debug script.""" | |
| print("\n" + "=" * 70) | |
| print("DEBUGGING RECOMMEND ENDPOINT") | |
| print("=" * 70) | |
| # Resolve a test user dynamically | |
| print("\n0️⃣ Resolving a test user...") | |
| try: | |
| resp = httpx.get( | |
| f"{SUPABASE_URL}/rest/v1/profiles", | |
| headers=HEADERS, | |
| params={"select": "id", "is_active": "eq.true", "limit": 1}, | |
| timeout=10, | |
| ) | |
| data = resp.json() | |
| if not data: | |
| print(" ✗ No active users found"); return | |
| user_id = data[0]["id"] | |
| print(f" ✓ Using user_id: {user_id}") | |
| except Exception as e: | |
| print(f" ✗ Could not resolve test user: {e}"); return | |
| # Run the full pipeline | |
| try: | |
| results = recommend(user_id) | |
| except Exception as e: | |
| print(f"\n✗ recommend() raised: {e}") | |
| import traceback; traceback.print_exc() | |
| return | |
| print(f"\n✅ recommend() returned {len(results)} result(s)") | |
| if results: | |
| print("\nTop 5 results:") | |
| print(f" {'Name':<25} {'Age':>4} {'Score':>7} Breakdown") | |
| print(" " + "-" * 70) | |
| for r in results[:5]: | |
| s = r["scores"] | |
| breakdown = ( | |
| f"age={s['age_compatibility']:.2f} " | |
| f"traits={s['personality_traits']:.2f} " | |
| f"criteria={s['partner_criteria']:.2f} " | |
| f"hobbies={s['hobbies']:.2f} " | |
| f"cat={s['categorical_bonus']:.2f}" | |
| ) | |
| print(f" {r['candidate_name']:<25} {r['age']:>4} {r['composite_score']:>7.4f} {breakdown}") | |
| if __name__ == "__main__": | |
| import asyncio | |
| asyncio.run(test_recommend()) |