107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
"""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)
|