diff --git a/projekt-matching/projektmatch/rules.py b/projekt-matching/projektmatch/rules.py new file mode 100644 index 0000000..a614dd8 --- /dev/null +++ b/projekt-matching/projektmatch/rules.py @@ -0,0 +1,106 @@ +"""Deterministic rules: Misc evaluation, match calculation, description markdown. + +Replicates SKILL.md Schritt 3b/4/5. No LLM here — the numbers written to the +CRM must never be hallucinated. +""" +from __future__ import annotations + +import datetime as dt +import json +import urllib.parse +import urllib.request +from math import asin, cos, radians, sin, sqrt + +SAUERLACH = (47.9721357, 11.6528398) +NOMINATIM_UA = "projekt-matching/1.0 (Thomas.Langer@destengs.com)" + +YES, NO, UNKNOWN = "yes", "no", "unknown" +SYMBOL = {YES: "✅", NO: "❌", UNKNOWN: "❔"} +RATING_VALUE = {YES: 100, UNKNOWN: 25, NO: 0} +KAT_ORDER = {"Must": 0, "Nice": 1, "Misc": 2} + + +def geocode(place: str): + """Nominatim lookup -> (lat, lon) or None. Policy: max 1 request/s.""" + url = "https://nominatim.openstreetmap.org/search?" + urllib.parse.urlencode( + {"q": place, "format": "json", "countrycodes": "de", "limit": 1}) + req = urllib.request.Request(url, headers={"User-Agent": NOMINATIM_UA}) + data = json.load(urllib.request.urlopen(req, timeout=20)) + return (float(data[0]["lat"]), float(data[0]["lon"])) if data else None + + +def distance_to_sauerlach_km(place: str, geocode_fn=geocode): + """Haversine distance; None on geocoding failure/ambiguity.""" + try: + pos = geocode_fn(place) + except Exception: + return None + if pos is None: + return None + dlat = radians(pos[0] - SAUERLACH[0]) + dlon = radians(pos[1] - SAUERLACH[1]) + h = (sin(dlat / 2) ** 2 + + cos(radians(SAUERLACH[0])) * cos(radians(pos[0])) * sin(dlon / 2) ** 2) + return 2 * 6371 * asin(sqrt(h)) + + +def eval_misc(item: dict, today: dt.date, geocode_fn=geocode) -> str: + kind = item.get("miscType") or "other" + if kind == "security": + return NO # K.-o.-Kriterium (SÜ dauert >= 3 Monate) + if kind == "start": + iso = item.get("startDate") + if not iso: + return UNKNOWN + try: + start = dt.date.fromisoformat(iso) + except ValueError: + return UNKNOWN + return YES if start <= today + dt.timedelta(days=56) else NO + if kind == "workload": + pct = item.get("workloadPercent") + if pct is None: + return UNKNOWN + return YES if 75 <= pct <= 100 else UNKNOWN + if kind == "duration": + return YES + if kind == "location": + if item.get("remotePercent") == 100: + return YES + place = item.get("onsiteLocation") + if not place: + return NO # Einsatzort/Remote-Anteil unklar + km = distance_to_sauerlach_km(place, geocode_fn) + if km is None: + return UNKNOWN + if km <= 50: + return YES + if km <= 60: + return UNKNOWN + return NO + return UNKNOWN + + +def calc_match(rows: list, kat: str): + vals = [RATING_VALUE[r["rating"]] for r in rows if r["kat"] == kat] + if not vals: + return None + return int(sum(vals) / len(vals) + 0.5) # half-up, not banker's rounding + + +def fmt_match(value): + return "–" if value is None else f"{value} %" + + +def order_rows(rows: list) -> list: + return sorted(rows, key=lambda r: KAT_ORDER[r["kat"]]) # stable sort + + +def build_description(rows: list) -> str: + """rows already ordered Must -> Nice -> Misc (use order_rows).""" + must, nice = calc_match(rows, "Must"), calc_match(rows, "Nice") + lines = [f"Must-have-Match: {fmt_match(must)} · Nice-to-have-Match: {fmt_match(nice)}", + "", "| Nr. | Kat. | ❔ | Anforderung |", "|---|---|---|---|"] + for i, r in enumerate(rows, 1): + lines.append(f"| {i} | {r['kat']} | {SYMBOL[r['rating']]} | {r['text']} |") + return "\n".join(lines) diff --git a/projekt-matching/tests/test_rules.py b/projekt-matching/tests/test_rules.py new file mode 100644 index 0000000..d0510c2 --- /dev/null +++ b/projekt-matching/tests/test_rules.py @@ -0,0 +1,96 @@ +import datetime as dt + +from projektmatch import rules + +TODAY = dt.date(2026, 7, 9) + + +def geo_munich(place): + return (48.137, 11.575) # ~40 km from Sauerlach + + +def geo_hamburg(place): + return (53.551, 9.994) # ~600 km + + +def geo_fail(place): + raise OSError("network down") + + +def misc(**kw): + base = {"miscType": "other", "startDate": None, "workloadPercent": None, + "remotePercent": None, "onsiteLocation": None} + base.update(kw) + return base + + +def test_security_is_always_no(): + assert rules.eval_misc(misc(miscType="security"), TODAY) == rules.NO + + +def test_start_within_8_weeks_yes_after_no_missing_unknown(): + assert rules.eval_misc(misc(miscType="start", startDate="2026-08-01"), TODAY) == rules.YES + assert rules.eval_misc(misc(miscType="start", startDate="2026-12-01"), TODAY) == rules.NO + assert rules.eval_misc(misc(miscType="start"), TODAY) == rules.UNKNOWN + assert rules.eval_misc(misc(miscType="start", startDate="asap"), TODAY) == rules.UNKNOWN + + +def test_workload(): + assert rules.eval_misc(misc(miscType="workload", workloadPercent=100), TODAY) == rules.YES + assert rules.eval_misc(misc(miscType="workload", workloadPercent=50), TODAY) == rules.UNKNOWN + assert rules.eval_misc(misc(miscType="workload"), TODAY) == rules.UNKNOWN + + +def test_duration_always_yes(): + assert rules.eval_misc(misc(miscType="duration"), TODAY) == rules.YES + + +def test_location_rules(): + full_remote = misc(miscType="location", remotePercent=100) + assert rules.eval_misc(full_remote, TODAY) == rules.YES + hybrid_near = misc(miscType="location", remotePercent=60, onsiteLocation="München") + assert rules.eval_misc(hybrid_near, TODAY, geocode_fn=geo_munich) == rules.YES + hybrid_far = misc(miscType="location", remotePercent=60, onsiteLocation="Hamburg") + assert rules.eval_misc(hybrid_far, TODAY, geocode_fn=geo_hamburg) == rules.NO + unclear = misc(miscType="location") + assert rules.eval_misc(unclear, TODAY) == rules.NO + geo_broken = misc(miscType="location", remotePercent=40, onsiteLocation="Xyzstadt") + assert rules.eval_misc(geo_broken, TODAY, geocode_fn=geo_fail) == rules.UNKNOWN + + +def test_calc_match_and_rounding(): + rows = [{"kat": "Must", "rating": "yes", "text": "a"}, + {"kat": "Must", "rating": "yes", "text": "b"}, + {"kat": "Must", "rating": "unknown", "text": "c"}, + {"kat": "Nice", "rating": "no", "text": "d"}, + {"kat": "Misc", "rating": "yes", "text": "e"}] + assert rules.calc_match(rows, "Must") == 75 # (100+100+25)/3 + assert rules.calc_match(rows, "Nice") == 0 + assert rules.calc_match([], "Nice") is None + # commercial rounding, half up (not banker's): (100+25)/2 = 62.5 -> 63 + two = [{"kat": "Must", "rating": "yes", "text": "a"}, + {"kat": "Must", "rating": "unknown", "text": "b"}] + assert rules.calc_match(two, "Must") == 63 + + +def test_build_description_exact_format(): + rows = [{"kat": "Misc", "rating": "yes", "text": "Hybrid: 60 % remote"}, + {"kat": "Must", "rating": "no", "text": "Mehrjährige LLM-Plattform-Erfahrung"}, + {"kat": "Must", "rating": "yes", "text": "RAG-Systeme"}, + {"kat": "Nice", "rating": "yes", "text": "vLLM"}] + text = rules.build_description(rules.order_rows(rows)) + assert text == ( + "Must-have-Match: 50 % · Nice-to-have-Match: 100 %\n" + "\n" + "| Nr. | Kat. | ❔ | Anforderung |\n" + "|---|---|---|---|\n" + "| 1 | Must | ❌ | Mehrjährige LLM-Plattform-Erfahrung |\n" + "| 2 | Must | ✅ | RAG-Systeme |\n" + "| 3 | Nice | ✅ | vLLM |\n" + "| 4 | Misc | ✅ | Hybrid: 60 % remote |") + + +def test_empty_category_dash(): + rows = [{"kat": "Must", "rating": "yes", "text": "a"}] + assert rules.build_description(rows).startswith( + "Must-have-Match: 100 % · Nice-to-have-Match: –")