Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 23 additions & 4 deletions app/employment_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
"parttime",
"minijob",
"mini-job",
"nebenjob",
"studentenjob",
"student job",
"geringfügige beschäftigung",
"geringfuegige beschaeftigung",
"20 hours per week",
Expand Down Expand Up @@ -48,6 +51,8 @@
"student assistant",
"studentische aushilfe",
"studentische hilfskraft",
"studentenjob",
"student job",
)
HOURS_PATTERN = re.compile(
r"(?<!\d)(?P<low>\d{1,2})(?:\s*(?:-|–|bis|to)\s*(?P<high>\d{1,2}))?"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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

Expand All @@ -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]
Expand All @@ -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))
2 changes: 1 addition & 1 deletion app/job_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)(?<!\d)(\d{1,2})(?:\s*(?:-|–|bis|to)\s*(\d{1,2}))?\s*"
r"(?:stunden|std\.?|hours?|h|wochenstunden)(?:\s*(?:pro\s+woche|per\s+week|/\s*woche))?\b"
Expand Down
8 changes: 7 additions & 1 deletion app/profile_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any

from .db import connection
from .employment_filter import profile_requires_confirmed_work_time
from .job_metadata import classify_job_metadata

PROFILE_SCHEMA = """
Expand Down Expand Up @@ -622,6 +623,8 @@ def list_jobs_for_profile(
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":
continue
Comment on lines +626 to +627

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the scoring classifier when filtering saved jobs

Confirmed vacancies are hidden when their work time is expressed only as weekly hours, workload percentage, PART_TIME provider metadata, or signals such as Student Assistant: assess_employment_fit recognizes these as part-time and can score/notify them, but classify_job_metadata still returns employment_type == "unknown". This post-query check consequently removes valid matches from the review queue; the filtering decision should reuse the employment assessment or make the metadata classifier recognize the same confirmation signals.

Useful? React with 👍 / 👎.

Comment on lines +626 to +627

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter unknown-work rows before applying LIMIT

When an upgraded database contains legacy non-excluded rows with genuinely unknown work time, the query selects the newest limit rows before this loop discards them. For example, 100 recent unknown rows can make the default API response empty even when older confirmed part-time matches exist, with no offset-based way for the UI to reach them. Apply the exclusion in the query or keep fetching until the requested number of eligible rows is collected.

Useful? React with 👍 / 👎.

item.pop("description", None)
item["remote"] = bool(item["remote"])
item["role_relevant"] = bool(item["role_relevant"])
Expand All @@ -632,7 +635,8 @@ def list_jobs_for_profile(
def get_job_for_profile(job_key: str, profile_id: int, user_id: int | None = None) -> 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:
Expand All @@ -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
9 changes: 7 additions & 2 deletions app/search_job_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions app/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 31 additions & 7 deletions tests/test_employment_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
Expand All @@ -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():
Expand Down
14 changes: 14 additions & 0 deletions tests/test_job_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
28 changes: 28 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
16 changes: 15 additions & 1 deletion tests/test_search_matching_pipeline_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = []

Expand Down Expand Up @@ -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
Loading