feat(projekt-matching): deterministic misc rules, match calc, description markdown

This commit is contained in:
tlg
2026-07-09 10:07:47 +02:00
parent 619a55f580
commit 3bbf6631fd
2 changed files with 202 additions and 0 deletions

View File

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

View File

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