Add public Cloudy profile matching sample
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
@@ -0,0 +1,37 @@
|
||||
# Profile Matching - Public Code Sample
|
||||
|
||||
This is a small, dependency-free portfolio excerpt adapted from **Cloudy**, an
|
||||
AI-assisted profile and matching service I built for a collaboration platform.
|
||||
The production repository is private, so this project intentionally contains
|
||||
only the self-contained scoring layer and its tests. It includes no user data,
|
||||
credentials, API code, or project-specific infrastructure.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- transparent, explainable matching across skills, interests, location, and
|
||||
shared project tags;
|
||||
- input normalisation and explicit handling of incomplete profile data;
|
||||
- optional affinity weights that personalise recommendations without changing
|
||||
existing behaviour when no preferences are set;
|
||||
- pure business logic separated from database and API concerns;
|
||||
- executable tests for normal cases, edge cases, and scoring invariants.
|
||||
|
||||
The actual application combines this score with an asynchronous PostgreSQL
|
||||
repository layer and uses it to rank potential collaboration matches.
|
||||
|
||||
## Run the tests
|
||||
|
||||
The sample uses only the Python standard library:
|
||||
|
||||
```bash
|
||||
python -m unittest -v
|
||||
```
|
||||
|
||||
## Why this is good code
|
||||
|
||||
The model is deliberately small and deterministic. Each function has one
|
||||
responsibility, is type annotated, and can be tested without a database, LLM,
|
||||
or network access. Missing location data never creates a false positive, and
|
||||
the weighted extension remains backwards-compatible with the original Jaccard
|
||||
similarity model. That keeps the recommendation logic understandable for users
|
||||
and safe to evolve in a larger product.
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Dependency-free, explainable scoring for profile recommendations.
|
||||
|
||||
Adapted as a public portfolio excerpt from Cloudy's matching service. In the
|
||||
application, an async repository layer supplies the profile attributes while
|
||||
this module remains intentionally independent of persistence and APIs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
|
||||
SKILL_WEIGHT = 0.30
|
||||
INTEREST_WEIGHT = 0.35
|
||||
LOCATION_WEIGHT = 0.15
|
||||
PROJECT_TAG_WEIGHT = 0.20
|
||||
LOCATION_DECAY_KM = 100.0
|
||||
|
||||
|
||||
def _normalise(items: Iterable[str]) -> set[str]:
|
||||
"""Return non-empty values as a case-insensitive set."""
|
||||
return {item.strip().casefold() for item in items if item and item.strip()}
|
||||
|
||||
|
||||
def jaccard_similarity(left: Iterable[str], right: Iterable[str]) -> float:
|
||||
"""Return set similarity in the inclusive range from 0.0 to 1.0."""
|
||||
left_values = _normalise(left)
|
||||
right_values = _normalise(right)
|
||||
union = left_values | right_values
|
||||
if not union:
|
||||
return 0.0
|
||||
return len(left_values & right_values) / len(union)
|
||||
|
||||
|
||||
def weighted_jaccard_similarity(
|
||||
left: Iterable[str],
|
||||
right: Iterable[str],
|
||||
affinity: Mapping[str, float] | None = None,
|
||||
) -> float:
|
||||
"""Personalise Jaccard similarity with optional preference weights.
|
||||
|
||||
Values absent from ``affinity`` remain neutral at 1.0. Therefore omitting
|
||||
preferences preserves the result of ``jaccard_similarity`` exactly.
|
||||
"""
|
||||
left_values = _normalise(left)
|
||||
right_values = _normalise(right)
|
||||
union = left_values | right_values
|
||||
if not union:
|
||||
return 0.0
|
||||
|
||||
weights = affinity or {}
|
||||
shared_weight = sum(weights.get(value, 1.0) for value in left_values & right_values)
|
||||
total_weight = sum(weights.get(value, 1.0) for value in union)
|
||||
return shared_weight / total_weight if total_weight else 0.0
|
||||
|
||||
|
||||
def haversine_distance_km(
|
||||
latitude_a: float, longitude_a: float, latitude_b: float, longitude_b: float
|
||||
) -> float:
|
||||
"""Calculate the great-circle distance between two geographic coordinates."""
|
||||
earth_radius_km = 6_371.0
|
||||
lat_a, lon_a, lat_b, lon_b = map(
|
||||
math.radians, (latitude_a, longitude_a, latitude_b, longitude_b)
|
||||
)
|
||||
delta_lat = lat_b - lat_a
|
||||
delta_lon = lon_b - lon_a
|
||||
area = math.sin(delta_lat / 2) ** 2 + math.cos(lat_a) * math.cos(lat_b) * math.sin(delta_lon / 2) ** 2
|
||||
return earth_radius_km * 2 * math.asin(math.sqrt(area))
|
||||
|
||||
|
||||
def location_similarity(
|
||||
left: tuple[float | None, float | None] | None,
|
||||
right: tuple[float | None, float | None] | None,
|
||||
decay_km: float = LOCATION_DECAY_KM,
|
||||
) -> float:
|
||||
"""Turn distance into a linear score; incomplete coordinates score 0.0."""
|
||||
if left is None or right is None or None in (*left, *right):
|
||||
return 0.0
|
||||
distance = haversine_distance_km(*left, *right) # type: ignore[arg-type]
|
||||
return max(0.0, 1.0 - distance / decay_km)
|
||||
|
||||
|
||||
def recommendation_score(
|
||||
skill_similarity: float,
|
||||
interest_similarity: float,
|
||||
location_similarity_score: float,
|
||||
project_tag_similarity: float,
|
||||
) -> float:
|
||||
"""Combine the four transparent score components into one ranking value."""
|
||||
return (
|
||||
SKILL_WEIGHT * skill_similarity
|
||||
+ INTEREST_WEIGHT * interest_similarity
|
||||
+ LOCATION_WEIGHT * location_similarity_score
|
||||
+ PROJECT_TAG_WEIGHT * project_tag_similarity
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for the public profile-matching code sample."""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from profile_matching import (
|
||||
INTEREST_WEIGHT,
|
||||
LOCATION_WEIGHT,
|
||||
PROJECT_TAG_WEIGHT,
|
||||
SKILL_WEIGHT,
|
||||
haversine_distance_km,
|
||||
jaccard_similarity,
|
||||
location_similarity,
|
||||
recommendation_score,
|
||||
weighted_jaccard_similarity,
|
||||
)
|
||||
|
||||
|
||||
class ProfileMatchingTests(unittest.TestCase):
|
||||
def test_component_weights_add_up_to_one(self) -> None:
|
||||
self.assertAlmostEqual(
|
||||
SKILL_WEIGHT + INTEREST_WEIGHT + LOCATION_WEIGHT + PROJECT_TAG_WEIGHT,
|
||||
1.0,
|
||||
)
|
||||
|
||||
def test_similarity_normalises_case_whitespace_and_duplicates(self) -> None:
|
||||
self.assertEqual(
|
||||
jaccard_similarity([" Python ", "python", ""], ["PYTHON"]), 1.0
|
||||
)
|
||||
|
||||
def test_empty_profiles_do_not_create_a_match(self) -> None:
|
||||
self.assertEqual(jaccard_similarity([], []), 0.0)
|
||||
self.assertEqual(location_similarity(None, (51.34, 12.37)), 0.0)
|
||||
|
||||
def test_unweighted_mode_is_backwards_compatible(self) -> None:
|
||||
left, right = ["Python", "Rust"], ["Rust", "TypeScript"]
|
||||
self.assertAlmostEqual(
|
||||
weighted_jaccard_similarity(left, right),
|
||||
jaccard_similarity(left, right),
|
||||
)
|
||||
|
||||
def test_shared_high_affinity_interest_increases_match_score(self) -> None:
|
||||
neutral = weighted_jaccard_similarity(
|
||||
["Hiking", "Opera"], ["Opera", "Cooking"]
|
||||
)
|
||||
personalised = weighted_jaccard_similarity(
|
||||
["Hiking", "Opera"], ["Opera", "Cooking"], {"opera": 3.0}
|
||||
)
|
||||
self.assertGreater(personalised, neutral)
|
||||
|
||||
def test_distance_is_symmetric_and_decays_to_zero(self) -> None:
|
||||
leipzig = (51.3397, 12.3731)
|
||||
berlin = (52.52, 13.405)
|
||||
self.assertAlmostEqual(
|
||||
haversine_distance_km(*leipzig, *berlin),
|
||||
haversine_distance_km(*berlin, *leipzig),
|
||||
)
|
||||
self.assertEqual(location_similarity(leipzig, berlin), 0.0)
|
||||
|
||||
def test_final_score_is_explainable_weighted_sum(self) -> None:
|
||||
self.assertAlmostEqual(
|
||||
recommendation_score(0.5, 0.5, 1.0, 0.0),
|
||||
0.475,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user