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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- Adds first-class profile fields for target role level, English ability, preferred weekly hours, and availability.
- Adds technician, engineering, and working-student role-level gates to prevent cross-seniority matches.
- Adds a structural role-relevance gate so location, language, schedule, and skill points cannot admit an unrelated occupation.
- Adds Strong, Match, Stretch, and Excluded result tiers plus a Match Tier filter in Job Review.
- Adds per-Search Job preferred or strict working-time handling.
Expand All @@ -14,6 +16,9 @@

### Fixed

- 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.
- Reclassifies legacy profile scores during migration and hides rows that were supported only by soft signals.
- Keeps title-relevant vacancies with incomplete source descriptions instead of failing them on an unevaluable CV Match.
- Prevents a slow or blocked JobSpy board from starving the other configured boards.
Expand All @@ -22,6 +27,8 @@

### Changed

- Reorganizes Search Profile editing around a compact essentials form and keeps raw scoring controls collapsed under Advanced settings.
- Applies pending guide changes automatically on save and shows profile language, hours, and target level on profile cards.
- Plans broad, unqualified role queries before schedule-specific variants and distributes capped provider budgets across requested role families.
- Runs independent sources concurrently while preserving deterministic source order.
- Keeps strong full-time or working-time-unclear roles as labeled stretch results by default; strict exclusion remains selectable per Search Job.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,20 @@ After signing in as the administrator:
4. Configure personal Telegram or email notifications in the user workspace.
5. Configure account-activation email under **Administration → System Email**.

The Search Profile editor keeps the everyday choices in **Profile essentials**: target role level, role families,
working arrangements, German and English ability, preferred weekly hours, availability, and provider-query
languages. Saving automatically adds generated bilingual suggestions while preserving existing custom terms.
Raw thresholds, exact query lists, allowlists, blocklists, and scoring JSON remain available under the collapsed
**Advanced matching settings** section.

Role level is an eligibility boundary rather than a score boost. A technician profile rejects engineering,
management, and student-only titles; an engineering profile rejects technician-only and student titles; a working-
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.

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.

System email and job-notification email are separate. System email settings can be managed in the admin UI; the `SYSTEM_SMTP_*` values in `.env` remain available as fallback defaults.

## Candidate Profiles and CV Match
Expand Down
45 changes: 39 additions & 6 deletions app/employment_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,36 @@ def _weekly_hours(text: str) -> int | None:
return min(values) if values else None


def _schedule_preference_reasons(profile: dict, body: str, weekly_hours: int | None) -> list[str]:
reasons: list[str] = []
preferred_hours = profile.get("preferred_weekly_hours")
if preferred_hours:
preferred_hours = int(preferred_hours)
if weekly_hours is None:
reasons.append(f"employment mismatch: preferred {preferred_hours} hours/week not confirmed")
elif weekly_hours > preferred_hours + 4:
reasons.append(
f"employment mismatch: advertised {weekly_hours} hours/week exceeds preferred {preferred_hours}"
)
else:
reasons.append(f"schedule: {weekly_hours} hours/week fits preferred {preferred_hours}")
availability = str(profile.get("availability") or "any")
schedule_is_flexible = any(
contains_affirmed_phrase(body, signal)
for signal in ("flexible working hours", "flexible arbeitszeiten", "gleitzeit")
)
schedule_is_afternoon = AFTERNOON_TIME_PATTERN.search(body) or any(
contains_affirmed_phrase(body, signal) for signal in AFTERNOON_SIGNALS
)
if availability == "afternoon" and not schedule_is_afternoon:
reasons.append("employment mismatch: afternoon availability not confirmed")
elif availability == "flexible" and not schedule_is_flexible:
reasons.append("employment mismatch: flexible hours not confirmed")
elif availability in {"afternoon", "flexible"}:
reasons.append(f"schedule: {availability} availability confirmed")
return reasons


def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple[bool, str, list[str]]:
"""Classify employment format, optionally enforcing it as a hard gate.

Expand All @@ -211,9 +241,6 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple
# full-time/part-time preference selected on the profile.
if student_only and not _profile_targets_students(profile):
return False, "student_only", ["employment mismatch: enrolled student required"]
if targets_part_time == targets_full_time:
return True, "not_restricted", []

part_time_terms = (
tuple(dict.fromkeys((*configured, *PART_TIME_SIGNALS))) if targets_part_time else PART_TIME_SIGNALS
)
Expand All @@ -231,9 +258,14 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple
full_time = True
if workload is not None and workload >= 90 and not part_time:
full_time = True
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:
label = "schedule_preference" if preference_reasons else "not_restricted"
return ((not strict) if preference_mismatch else True), label, preference_reasons

if targets_part_time and part_time:
reasons = ["employment: part-time/student confirmed"]
reasons = ["employment: part-time/student confirmed", *preference_reasons]
if weekly_hours is not None:
reasons.append(f"schedule: {weekly_hours} hours/week")
if workload is not None:
Expand All @@ -242,7 +274,7 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple
contains_affirmed_phrase(body, signal) for signal in AFTERNOON_SIGNALS
):
reasons.append("schedule: afternoon/flexible")
return True, "part_time", reasons
return ((not strict) if preference_mismatch else True), "part_time", reasons
if targets_part_time and full_time:
reasons = ["employment mismatch: full-time"]
return (not strict), "full_time", reasons
Expand All @@ -251,7 +283,8 @@ def assess_employment_fit(job: Job, profile: dict, strict: bool = True) -> tuple
return (not strict), "unclear", reasons

if full_time:
return True, "full_time", ["employment: full-time confirmed"]
reasons = ["employment: full-time confirmed", *preference_reasons]
return ((not strict) if preference_mismatch else True), "full_time", reasons
if part_time:
reasons = ["employment mismatch: part-time/student"]
return (not strict), "part_time", reasons
Expand Down
10 changes: 7 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from pathlib import Path
import asyncio
import hashlib
from typing import Any
from typing import Any, Literal
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
Expand Down Expand Up @@ -171,8 +171,12 @@ class ProfilePayload(BaseModel):
min_score: int = Field(35, ge=0, le=100)
min_language_score: int = Field(40, ge=0, le=100)
language_weight: int = Field(35, ge=0, le=100)
current_german_level: str = "a2_b1"
max_german_requirement: str = "b1"
current_german_level: Literal["a1", "a2", "a2_b1", "b1", "b2"] = "a2_b1"
current_english_level: Literal["a2", "b1", "b2", "c1", "c2"] = "b1"
max_german_requirement: Literal["a2", "b1", "b2"] = "b1"
preferred_weekly_hours: int | None = Field(None, ge=1, le=48)
availability: Literal["any", "afternoon", "flexible"] = "any"
role_level: Literal["any", "technician", "engineer", "student"] = "any"
show_b2_stretch: bool = True
hide_german_heavy: bool = True
prefer_german_growth: bool = True
Expand Down
Loading
Loading