"""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)