From 35ffa384d74f05b00d10b3420f4889463c6f8ec1 Mon Sep 17 00:00:00 2001 From: emnl51 Date: Fri, 4 Sep 2026 14:10:03 +0200 Subject: [PATCH] Exclude unknown work time from part-time searches --- CHANGELOG.md | 1 + README.md | 4 +++ app/employment_filter.py | 27 +++++++++++++--- app/job_metadata.py | 2 +- app/profile_store.py | 8 ++++- app/search_job_service.py | 9 ++++-- app/service.py | 4 +-- tests/test_employment_filter.py | 38 ++++++++++++++++++----- tests/test_job_metadata.py | 14 +++++++++ tests/test_profiles.py | 28 +++++++++++++++++ tests/test_search_matching_pipeline_v2.py | 16 +++++++++- 11 files changed, 133 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb0bee..b210f10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Fixed +- Excludes unresolved `Work time unknown` vacancies from part-time and working-student searches in every mode. - Prevents guided profile edits from silently replacing custom queries and role terms. - Preserves and reloads English level, weekly hours, and availability after a profile is saved. - Returns an actionable list of linked Search Jobs instead of a server error when a referenced profile is deleted. diff --git a/README.md b/README.md index e6fca7a..a568629 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,10 @@ management, and student-only titles; an engineering profile rejects technician-o student profile requires an explicit student-role signal. Weekly hours and availability act as labeled preferences for Search Jobs in preference mode and as exclusions in strict working-time mode. +Part-time and working-student profiles always require a confirmed work type after Bert inspects the job title, +available description, hours/workload text, and provider metadata. A vacancy that still shows **Work time unknown** is +excluded from review and notifications even when its Search Job uses preference mode. + Profiles referenced by Search Jobs cannot be deleted. Bert reports the linked Search Job names so they can be reassigned or removed first; profile-specific scores are deleted only after those references are resolved. diff --git a/app/employment_filter.py b/app/employment_filter.py index 9031854..d4596ed 100644 --- a/app/employment_filter.py +++ b/app/employment_filter.py @@ -17,6 +17,9 @@ "parttime", "minijob", "mini-job", + "nebenjob", + "studentenjob", + "student job", "geringfügige beschäftigung", "geringfuegige beschaeftigung", "20 hours per week", @@ -48,6 +51,8 @@ "student assistant", "studentische aushilfe", "studentische hilfskraft", + "studentenjob", + "student job", ) HOURS_PATTERN = re.compile( r"(?\d{1,2})(?:\s*(?:-|–|bis|to)\s*(?P\d{1,2}))?" @@ -101,6 +106,11 @@ def profile_targets_full_time(profile: dict) -> bool: return any(term in FULL_TIME_SIGNALS for term in format_terms) +def profile_requires_confirmed_work_time(profile: dict) -> bool: + """Return whether an unknown work type must never enter the review queue.""" + return profile_targets_part_time(profile) or str(profile.get("role_level") or "any") == "student" + + QUERY_ARRANGEMENT_PATTERN = re.compile( r"(?i)\b(?:werkstudent\w*|working student|student assistant|studentische(?:r|n)? \w+|" r"teilzeit|part[ -]?time|full[ -]?time|vollzeit|minijob|mini-job|geringf(?:ü|ue)gig\w*)\b" @@ -227,10 +237,10 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple """Classify employment format, optionally enforcing it as a hard gate. A positive part-time/student signal wins over generic full-time boilerplate. For a - strict part-time search, explicit full-time jobs and jobs with no confirmable target - format are rejected. In preference mode they remain visible as stretch results. + part-time or student search, jobs with no confirmable work type are always rejected. + Explicitly different work types remain visible only when preference mode allows them. """ - targets_part_time = profile_targets_part_time(profile) + targets_part_time = profile_targets_part_time(profile) or str(profile.get("role_level") or "any") == "student" targets_full_time = profile_targets_full_time(profile) title = _norm(job.title) body = _norm(f"{job.title} {job.description}") @@ -261,6 +271,8 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple preference_reasons = _schedule_preference_reasons(profile, body, weekly_hours) preference_mismatch = any(reason.startswith("employment mismatch:") for reason in preference_reasons) if targets_part_time == targets_full_time: + if targets_part_time and not part_time and not full_time: + return False, "unclear", ["employment mismatch: working time not confirmed", *preference_reasons] label = "schedule_preference" if preference_reasons else "not_restricted" return ((not strict) if preference_mismatch else True), label, preference_reasons @@ -280,7 +292,7 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple return (not strict), "full_time", reasons if targets_part_time: reasons = ["employment mismatch: part-time/minijob not confirmed"] - return (not strict), "unclear", reasons + return False, "unclear", reasons if full_time: reasons = ["employment: full-time confirmed", *preference_reasons] @@ -289,3 +301,10 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple reasons = ["employment mismatch: part-time/student"] return (not strict), "part_time", reasons return (not strict), "unclear", ["employment mismatch: full-time not confirmed"] + + +def is_hard_employment_exclusion(profile: dict, employment_ok: bool, label: str, strict: bool = False) -> bool: + """Keep unknown work time out of student/part-time review queues in every mode.""" + if employment_ok: + return False + return strict or label == "student_only" or (label == "unclear" and profile_requires_confirmed_work_time(profile)) diff --git a/app/job_metadata.py b/app/job_metadata.py index 76cf76e..f81b3a8 100644 --- a/app/job_metadata.py +++ b/app/job_metadata.py @@ -7,7 +7,7 @@ _PART_TIME_RE = re.compile(r"(?i)\b(teilzeit|part[ -]?time|nebenjob)\b") _FULL_TIME_RE = re.compile(r"(?i)\b(vollzeit|full[ -]?time)\b") _MINIJOB_RE = re.compile(r"(?i)\b(mini[ -]?job|geringfügig\w*|geringfuegig\w*)\b") -_STUDENT_RE = re.compile(r"(?i)\b(werkstudent\w*|working student|studentische hilfskraft)\b") +_STUDENT_RE = re.compile(r"(?i)\b(werkstudent\w*|working student|studentische hilfskraft|studentenjob|student job)\b") _HOURS_RE = re.compile( r"(?i)(? dict[str, Any] | None: """Return one complete job only when it belongs to the user's search profile.""" ensure_profile_schema(user_id) - if not get_profile(profile_id, user_id=user_id): + profile = get_profile(profile_id, user_id=user_id) + if not profile: return None owner_key = "admin" if user_id is None else f"user:{int(user_id)}" with connection() as con: @@ -656,6 +660,8 @@ def get_job_for_profile(job_key: str, profile_id: int, user_id: int | None = Non item["reasons"] = json.loads(item.pop("reasons_json") or "[]") item["language_reasons"] = json.loads(item.pop("language_reasons_json") or "[]") item.update(classify_job_metadata(item)) + if profile_requires_confirmed_work_time(profile) and item["employment_type"] == "unknown": + return None item["remote"] = bool(item["remote"]) item["role_relevant"] = bool(item["role_relevant"]) return item diff --git a/app/search_job_service.py b/app/search_job_service.py index 6f7e997..8952dbc 100644 --- a/app/search_job_service.py +++ b/app/search_job_service.py @@ -3,7 +3,7 @@ from copy import deepcopy from urllib.parse import urlsplit, urlunsplit from .db import list_sources, mark_notified, upsert_job -from .employment_filter import assess_employment_fit, search_terms_for_profile +from .employment_filter import assess_employment_fit, is_hard_employment_exclusion, search_terms_for_profile from .language_store import upsert_language_fit from .notifier import send_email, send_telegram from .positive_learning import apply_positive_boost, sync_application_events @@ -223,7 +223,12 @@ async def run_search_job(search_job_id: int) -> dict: upsert_profile_score(job, profile["id"], role_relevant=False, match_tier="excluded") continue - hard_employment_exclusion = not employment_ok and (strict_employment or _employment_label == "student_only") + hard_employment_exclusion = is_hard_employment_exclusion( + profile, + employment_ok, + _employment_label, + strict=strict_employment, + ) if hard_employment_exclusion: filtered["employment"] += 1 job.match_tier = "excluded" diff --git a/app/service.py b/app/service.py index a7dbbc1..756ca59 100644 --- a/app/service.py +++ b/app/service.py @@ -2,7 +2,7 @@ from copy import deepcopy from .db import create_run, finish_run, mark_notified, upsert_job -from .employment_filter import assess_employment_fit, search_terms_for_profile +from .employment_filter import assess_employment_fit, is_hard_employment_exclusion, search_terms_for_profile from .feedback_store import apply_learned_penalty from .positive_learning import apply_positive_boost, sync_application_events from .notifier import send_email, send_telegram @@ -157,7 +157,7 @@ async def run_search() -> dict: and job.overall_score >= int(profile.get("min_score", 35)) and eligible_language ) - if not employment_ok and _employment_label == "student_only": + if is_hard_employment_exclusion(profile, employment_ok, _employment_label): job.match_tier = "excluded" else: job.match_tier = classify_match_tier( diff --git a/tests/test_employment_filter.py b/tests/test_employment_filter.py index 2c77344..a6c53a7 100644 --- a/tests/test_employment_filter.py +++ b/tests/test_employment_filter.py @@ -46,6 +46,13 @@ def test_minijob_is_eligible_even_if_not_in_old_profile_json(): assert label == "part_time" +def test_nebenjob_and_studentenjob_are_confirmed_work_types(): + for title in ("Nebenjob Einkauf", "Studentenjob Supply Chain"): + ok, label, _ = assess_employment_fit(job(title), PROFILE, strict=False) + assert ok is True + assert label == "part_time" + + def test_explicit_full_time_is_rejected(): ok, label, reasons = assess_employment_fit( job("Supply Chain Specialist", "Employment type: fulltime. Permanent position."), PROFILE @@ -55,13 +62,26 @@ def test_explicit_full_time_is_rejected(): assert "employment mismatch: full-time" in reasons -def test_unknown_format_is_rejected_for_strict_part_time_profile(): - ok, label, reasons = assess_employment_fit( - job("Supply Chain Specialist", "International procurement and SAP responsibilities."), PROFILE - ) - assert ok is False - assert label == "unclear" - assert any("not confirmed" in r for r in reasons) +def test_unknown_format_is_always_rejected_for_part_time_profile(): + vacancy = job("Supply Chain Specialist", "International procurement and SAP responsibilities.") + + for strict in (False, True): + ok, label, reasons = assess_employment_fit(vacancy, PROFILE, strict=strict) + assert ok is False + assert label == "unclear" + assert any("not confirmed" in r for r in reasons) + + +def test_unknown_format_can_remain_a_preference_only_for_full_time_profile(): + profile = { + "name": "Quality engineering / Full-time", + "slug": "quality-full-time", + "keywords": {"format": {"Vollzeit": 16, "full time": 16}}, + } + vacancy = job("Quality Engineer", "Manufacturing quality systems and supplier development.") + + assert assess_employment_fit(vacancy, profile, strict=False)[:2] == (True, "unclear") + assert assess_employment_fit(vacancy, profile, strict=True)[:2] == (False, "unclear") def test_mixed_full_and_part_time_profile_accepts_both_and_keeps_queries(): @@ -82,6 +102,10 @@ def test_mixed_full_and_part_time_profile_accepts_both_and_keeps_queries(): assert "Qualitätsprüfer Teilzeit" in terms assert assess_employment_fit(job("Qualitätsprüfer Vollzeit"), mixed)[0] is True assert assess_employment_fit(job("Qualitätsprüfer Teilzeit"), mixed)[0] is True + assert assess_employment_fit(job("Qualitätsprüfer", "Bauteile prüfen."), mixed, strict=False)[:2] == ( + False, + "unclear", + ) def test_first_class_hours_and_availability_are_constraints_or_preferences(): diff --git a/tests/test_job_metadata.py b/tests/test_job_metadata.py index 42aa9c6..a4c6929 100644 --- a/tests/test_job_metadata.py +++ b/tests/test_job_metadata.py @@ -49,6 +49,20 @@ def test_metadata_falls_back_to_first_seen_without_inventing_work_type(): assert metadata["data_quality"] < 80 +def test_studentenjob_is_not_reported_as_unknown_work_time(): + metadata = classify_job_metadata( + { + "title": "Studentenjob Einkauf", + "description": "Unterstützung des Supply-Chain-Teams.", + "company": "Example GmbH", + "location": "Berlin", + } + ) + + assert metadata["employment_type"] == "working_student" + assert metadata["employment_label"] == "Working student" + + def test_metadata_prefers_iso_publication_date_over_first_seen(): metadata = classify_job_metadata( { diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 872e443..2b37e41 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -170,6 +170,34 @@ def test_complete_job_detail_is_limited_to_scored_profile(tmp_path, monkeypatch) assert get_job_for_profile(job.key, profiles[1]["id"]) is None +def test_existing_unknown_work_time_rows_are_hidden_for_part_time_profiles(tmp_path, monkeypatch): + setup_db(tmp_path, monkeypatch) + profile_id = save_profile( + { + "name": "Part-time quality", + "slug": "part-time-quality", + "keywords": {"format": {"Teilzeit": 16, "part time": 16}}, + } + ) + vacancy = Job( + source="test", + external_id="unknown-hours", + title="Mitarbeiter Qualitätskontrolle", + company="Example GmbH", + location="Berlin", + url="https://example.com/unknown-hours", + description="Prüfung und Dokumentation von Bauteilen.", + ) + vacancy.score = 75 + vacancy.language_score = 80 + vacancy.overall_score = 77 + db.upsert_job(vacancy) + upsert_profile_score(vacancy, profile_id, role_relevant=True, match_tier="match") + + assert list_jobs_for_profile(profile_id, decision="all", language="all") == [] + assert get_job_for_profile(vacancy.key, profile_id) is None + + def test_role_irrelevant_scores_are_not_shown_in_review_queue(tmp_path, monkeypatch): setup_db(tmp_path, monkeypatch) profile = list_profiles()[0] diff --git a/tests/test_search_matching_pipeline_v2.py b/tests/test_search_matching_pipeline_v2.py index 9a2a401..23f25f4 100644 --- a/tests/test_search_matching_pipeline_v2.py +++ b/tests/test_search_matching_pipeline_v2.py @@ -34,7 +34,7 @@ @pytest.mark.parametrize( ("employment_mode", "expected_matches", "process_tier", "employment_filtered"), - (("prefer", 2, "stretch", 0), ("strict", 1, "excluded", 1)), + (("prefer", 2, "stretch", 1), ("strict", 1, "excluded", 2)), ) def test_pipeline_separates_role_relevance_from_working_time_constraints( monkeypatch, employment_mode, expected_matches, process_tier, employment_filtered @@ -90,6 +90,15 @@ def test_pipeline_separates_role_relevance_from_working_time_constraints( url="https://example.com/quality-short", description="Part-time role in an English-speaking team.", ), + Job( + source="test", + external_id="quality-unknown-time", + title="Quality Engineer", + company="Example Manufacturing", + location="Berlin", + url="https://example.com/quality-unknown-time", + description="Manufacturing quality systems with SPC and FMEA in an English-speaking team.", + ), ] writes = [] @@ -135,3 +144,8 @@ async def fetch_all(_sources, _terms, _location): assert result["filtered"]["employment"] == employment_filtered assert ("process", process_tier, {"role_relevant": True, "match_tier": process_tier}) in writes assert ("quality-short", "stretch", {"role_relevant": True, "match_tier": "stretch"}) in writes + assert ( + "quality-unknown-time", + "excluded", + {"role_relevant": True, "match_tier": "excluded"}, + ) in writes