From 39601458b318fa3d1d627c7bde40519fc9e65fa8 Mon Sep 17 00:00:00 2001 From: emnl51 Date: Thu, 3 Sep 2026 20:26:36 +0200 Subject: [PATCH] Redesign search profile editor --- CHANGELOG.md | 7 +++ README.md | 14 ++++++ app/employment_filter.py | 45 +++++++++++++++++--- app/main.py | 10 +++-- app/profile-ui.js | 55 +++++++++++++----------- app/profile_store.py | 66 ++++++++++++++++++++++++++--- app/ranker.py | 49 +++++++++++++++++++++ app/search_job_service.py | 1 + app/service.py | 2 +- app/ui-shell.js | 8 ++-- tests/test_employment_filter.py | 19 +++++++++ tests/test_profile_guide.py | 31 ++++++++++++-- tests/test_profiles.py | 75 +++++++++++++++++++++++++++++++++ tests/test_role_relevance_v2.py | 23 ++++++++++ 14 files changed, 358 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af4dd68..6eb0bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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. @@ -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. diff --git a/README.md b/README.md index ee5cb35..e6fca7a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/employment_filter.py b/app/employment_filter.py index 123179a..9031854 100644 --- a/app/employment_filter.py +++ b/app/employment_filter.py @@ -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. @@ -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 ) @@ -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: @@ -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 @@ -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 diff --git a/app/main.py b/app/main.py index a205ab2..76d11ae 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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 diff --git a/app/profile-ui.js b/app/profile-ui.js index 9ed2018..78cb22b 100644 --- a/app/profile-ui.js +++ b/app/profile-ui.js @@ -1,10 +1,11 @@ (() => { - const $=id=>document.getElementById(id); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); let profiles=[]; + const $=id=>document.getElementById(id); const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); let profiles=[],guideDirty=false; const style=document.createElement('style');style.textContent=`.profile-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}.profile-card{position:relative}.profile-card.default{border-color:#9bb4f5}.profile-badges{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.profile-actions{display:flex;gap:7px;flex-wrap:wrap;margin-top:14px}.profile-modal{position:fixed;inset:0;background:rgba(15,23,42,.55);display:none;align-items:center;justify-content:center;z-index:100;padding:18px}.profile-modal.open{display:flex}.profile-dialog{background:#fff;border-radius:16px;max-width:900px;width:100%;max-height:92vh;overflow:auto;padding:22px;box-shadow:0 18px 60px rgba(0,0,0,.25)}.profile-dialog textarea{min-height:170px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.profile-checks{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px}`;document.head.appendChild(style); - style.textContent+=`.profile-guide{margin:0 0 18px;padding:15px;border:1px solid #d7e2fb;border-radius:14px;background:#f7f9ff}.profile-guide summary{cursor:pointer;font-size:14px;font-weight:750;color:#233b72}.profile-guide-body{display:grid;gap:13px;margin-top:14px}.profile-guide-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.profile-guide-options{display:flex;gap:8px;flex-wrap:wrap}.profile-guide-option{display:inline-flex;align-items:center;gap:5px;padding:6px 9px;border:1px solid #d7dfed;border-radius:9px;background:#fff;font-size:11px}.profile-guide-preview{padding:11px;border:1px solid #e0e6f2;border-radius:10px;background:#fff}.profile-guide-preview code{display:block;padding:3px 0;color:#334155;font-size:11px}.profile-guide-checks{display:grid;gap:5px;margin-top:8px;font-size:11px}.profile-guide-ok{color:#24734e}.profile-guide-warning{color:#946200}.profile-guide-actions{display:flex;gap:8px;flex-wrap:wrap}@media(max-width:700px){.profile-guide-row{grid-template-columns:1fr}.profile-guide-actions .btn{width:100%}}`; + style.textContent+=`.profile-basics{margin-bottom:14px}.profile-guide{margin:0 0 14px;padding:15px;border:1px solid #d7e2fb;border-radius:14px;background:#f7f9ff}.profile-guide summary,.profile-advanced summary{cursor:pointer;font-size:14px;font-weight:750;color:#233b72}.profile-guide-body{display:grid;gap:13px;margin-top:14px}.profile-guide-row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px}.profile-guide-options{display:flex;gap:8px;flex-wrap:wrap}.profile-guide-option{display:inline-flex;align-items:center;gap:5px;padding:6px 9px;border:1px solid #d7dfed;border-radius:9px;background:#fff;font-size:11px}.profile-guide-preview{padding:11px;border:1px solid #e0e6f2;border-radius:10px;background:#fff}.profile-guide-preview code{display:block;padding:3px 0;color:#334155;font-size:11px}.profile-guide-checks{display:grid;gap:5px;margin-top:8px;font-size:11px}.profile-guide-ok{color:#24734e}.profile-guide-warning{color:#946200}.profile-guide-actions{display:flex;gap:8px;flex-wrap:wrap}.profile-advanced{margin-top:12px;padding:13px;border:1px solid var(--line);border-radius:12px}.profile-advanced>.form-grid{margin-top:15px}.profile-dialog>.actions{position:sticky;bottom:-22px;margin:18px -22px -22px;padding:13px 22px;background:rgba(255,255,255,.96);border-top:1px solid var(--line);justify-content:flex-end;backdrop-filter:blur(10px)}@media(max-width:700px){.profile-guide-row{grid-template-columns:1fr}.profile-guide-actions .btn,.profile-dialog>.actions .btn{width:100%}.profile-dialog>.actions{display:grid}}`; const GUIDE_ROLES={ - quality:{label:'Quality / inspection',de:'Qualitätskontrolle',en:'quality control',aliases:['quality control','quality assurance','quality inspection','Qualitätskontrolle','Qualitätssicherung','Qualitätsprüfung']}, + quality_technician:{label:'Quality control / technician',de:'Qualitätsprüfer',en:'quality technician',aliases:['quality technician','quality inspector','quality control technician','quality control','Qualitätstechniker','Qualitätsprüfer','Qualitätskontrolleur','Qualitätskontrolle','Mitarbeiter Qualitätssicherung','Wareneingangsprüfer','Prüftechniker']}, + quality_engineering:{label:'Quality engineering',de:'Qualitätsingenieur',en:'quality engineer',aliases:['quality engineer','quality engineering','Qualitätsingenieur','Qualitätsingenieur Produktion']}, production:{label:'Production / manufacturing',de:'Produktionsassistenz',en:'production assistant',aliases:['production assistant','production support','Produktionsassistenz','Produktionsmitarbeiter']}, planning:{label:'Production planning',de:'Arbeitsvorbereitung',en:'production planning',aliases:['production planning','production planner','Arbeitsvorbereitung','Produktionsplanung']}, process:{label:'Process engineering',de:'Prozessingenieur',en:'process engineer',aliases:['process engineer','process engineering','Prozessingenieur','Prozessoptimierung']}, @@ -19,11 +20,12 @@ student:{label:'Working student / Werkstudent',de:'Werkstudent',en:'working student',terms:['Werkstudent','working student']}, }; const GUIDE_PRESETS={ - quality_both:{name:'Quality inspection / Full-time and part-time',roles:['quality','production'],formats:['fulltime','parttime'],german:'a2_b1',english:'b1',availability:'any',hours:''}, - technical_parttime:{name:'Technical support / Part-time',roles:['quality','production','planning','office'],formats:['parttime','minijob'],german:'a2_b1',english:'b1',availability:'afternoon',hours:20}, - engineering_fulltime:{name:'Engineering / Full-time',roles:['quality','process','planning'],formats:['fulltime'],german:'b1',english:'b2',availability:'any',hours:''}, - office_minijob:{name:'Technical office / Minijob',roles:['office','procurement','logistics'],formats:['parttime','minijob'],german:'a2_b1',english:'b1',availability:'afternoon',hours:15}, + quality_both:{name:'Quality technician / Full-time and part-time',roles:['quality_technician'],formats:['fulltime','parttime'],level:'technician',german:'a2_b1',english:'b1',availability:'any',hours:''}, + technical_parttime:{name:'Technical support / Part-time',roles:['quality_technician','production','planning','office'],formats:['parttime','minijob'],level:'technician',german:'a2_b1',english:'b1',availability:'afternoon',hours:20}, + engineering_fulltime:{name:'Engineering / Full-time',roles:['quality_engineering','process','planning'],formats:['fulltime'],level:'engineer',german:'b1',english:'b2',availability:'any',hours:''}, + office_minijob:{name:'Technical office / Minijob',roles:['office','procurement','logistics'],formats:['parttime','minijob'],level:'any',german:'a2_b1',english:'b1',availability:'afternoon',hours:15}, }; + const ROLE_LEVEL_LABELS={any:'Any suitable level',technician:'Technician / inspection staff',engineer:'Engineer',student:'Working student'}; function buildProfileGuidePlan(selection){ const roles=(selection.roles||[]).map(key=>GUIDE_ROLES[key]).filter(Boolean); @@ -41,6 +43,9 @@ if(!formats.length)warnings.push('Choose at least one eligible working arrangement.'); if(!languages.length)warnings.push('Choose German or English provider queries.'); if((selection.formats||[]).includes('student')&&!selection.enrolled)warnings.push('Working-student searches require current university enrollment.'); + if(selection.roleLevel==='student'&&!(selection.formats||[]).includes('student'))warnings.push('Student level requires the Working student arrangement.'); + if(selection.roleLevel==='technician'&&(selection.roles||[]).includes('quality_engineering'))warnings.push('Quality engineering conflicts with technician level.'); + if(selection.roleLevel==='engineer'&&(selection.roles||[]).includes('quality_technician'))warnings.push('Quality technician conflicts with engineering level.'); if((selection.formats||[]).includes('fulltime')&&((selection.formats||[]).includes('parttime')||(selection.formats||[]).includes('minijob')))warnings.push('Create separate full-time and part-time profiles for more precise matches.'); if(queries.length>8)warnings.push('Some providers only run the first 6–8 queries; role families are interleaved automatically.'); const scheduleTerms=selection.availability==='afternoon'?['nachmittags','ab 14 Uhr','afternoon']:selection.availability==='flexible'?['flexible Arbeitszeiten','flexible working hours']:[]; @@ -53,48 +58,50 @@ roles:Object.keys(GUIDE_ROLES).filter(key=>$(`pfGuideRole-${key}`)?.checked), formats:Object.keys(GUIDE_FORMATS).filter(key=>$(`pfGuideFormat-${key}`)?.checked), languages:[...$('pfGuideLanguageDe')?.checked?['de']:[],...$('pfGuideLanguageEn')?.checked?['en']:[]], - enrolled:Boolean($('pfGuideEnrolled')?.checked),availability:$('pfGuideAvailability')?.value||'any',hours:$('pfGuideHours')?.value||'', + enrolled:Boolean($('pfGuideEnrolled')?.checked),availability:$('pfGuideAvailability')?.value||'any',hours:$('pfGuideHours')?.value||'',roleLevel:$('pfGuideRoleLevel')?.value||'any', }} + function profileGuideLevelError(selection){if(selection.roleLevel==='student'&&!(selection.formats||[]).includes('student'))return 'Student level requires the Working student arrangement.';if(selection.roleLevel==='technician'&&(selection.roles||[]).includes('quality_engineering'))return 'Quality engineering conflicts with technician level.';if(selection.roleLevel==='engineer'&&(selection.roles||[]).includes('quality_technician'))return 'Quality technician conflicts with engineering level.';return ''} window.refreshProfileGuide=function(){const plan=buildProfileGuidePlan(guideSelection()),preview=$('pfGuideQueryPreview'),checks=$('pfGuideChecks');if(!preview||!checks)return plan;preview.innerHTML=plan.queries.length?plan.queries.slice(0,8).map(query=>`${esc(query)}`).join(''):'Choose a role and working arrangement to preview search phrases.';const ready=[plan.roles.length&&`${plan.roles.length} bilingual role terms`,plan.formats.length&&`${plan.formats.length} working-arrangement terms`,plan.queries.length&&`${plan.queries.length} provider-ready queries`].filter(Boolean);checks.innerHTML=[...ready.map(text=>`
✓ ${esc(text)}
`),...plan.warnings.map(text=>`
! ${esc(text)}
`)].join('');return plan}; - window.applyProfileGuidePreset=function(){const preset=GUIDE_PRESETS[$('pfGuidePreset')?.value];if(!preset)return;Object.keys(GUIDE_ROLES).forEach(key=>{$(`pfGuideRole-${key}`).checked=preset.roles.includes(key)});Object.keys(GUIDE_FORMATS).forEach(key=>{$(`pfGuideFormat-${key}`).checked=preset.formats.includes(key)});$('pfGuideGerman').value=preset.german;$('pfGuideEnglish').value=preset.english;$('pfGuideAvailability').value=preset.availability;$('pfGuideHours').value=preset.hours;$('pfGuideLanguageDe').checked=true;$('pfGuideLanguageEn').checked=true;$('pfGuideEnrolled').checked=false;if(!$('pfName').value.trim())$('pfName').value=preset.name;return refreshProfileGuide()}; + window.applyProfileGuidePreset=function(){const preset=GUIDE_PRESETS[$('pfGuidePreset')?.value];if(!preset)return;Object.keys(GUIDE_ROLES).forEach(key=>{$(`pfGuideRole-${key}`).checked=preset.roles.includes(key)});Object.keys(GUIDE_FORMATS).forEach(key=>{$(`pfGuideFormat-${key}`).checked=preset.formats.includes(key)});$('pfGuideRoleLevel').value=preset.level;$('pfGuideGerman').value=preset.german;$('pfGuideEnglish').value=preset.english;$('pfGuideAvailability').value=preset.availability;$('pfGuideHours').value=preset.hours;$('pfGuideLanguageDe').checked=true;$('pfGuideLanguageEn').checked=true;$('pfGuideEnrolled').checked=preset.level==='student';guideDirty=true;if(!$('pfName').value.trim())$('pfName').value=preset.name;return refreshProfileGuide()}; - window.applyProfileGuide=function(){const selection=guideSelection(),plan=buildProfileGuidePlan(selection);if(!plan.roles.length||!plan.formats.length||!plan.queries.length){refreshProfileGuide();toast('Complete the profile guide before applying suggestions',true);return false}$('pfRolePhrases').value=plan.roles.join('\n');$('pfFormatPhrases').value=plan.formats.join('\n');$('pfSearchPhrases').value=plan.queries.join('\n');$('pfGerman').value=$('pfGuideGerman').value;$('pfGermanMax').value=['a1','a2'].includes($('pfGuideGerman').value)?'a2':$('pfGuideGerman').value==='b2'?'b2':'b1';$('pfContentDe').checked=selection.languages.includes('de');$('pfContentEn').checked=selection.languages.includes('en');$('pfContentMixed').checked=selection.languages.length>1;let keywords;try{keywords=JSON.parse($('pfKeywords').value||'{}')}catch(_){keywords={}}keywords.allowlist=keywords.allowlist||{};for(const term of plan.scheduleTerms)if(!Object.prototype.hasOwnProperty.call(keywords.allowlist,term))keywords.allowlist[term]=10;keywords.language={[`english_${$('pfGuideEnglish').value}`]:0};$('pfKeywords').value=JSON.stringify(keywords,null,2);refreshProfileGuide();toast('Guided profile suggestions applied');return true}; + function mergeGuideTerms(id,terms){const existing=$(id).value.split('\n').map(x=>x.trim()).filter(Boolean);$(id).value=[...new Set([...existing,...terms])].join('\n')} + window.applyProfileGuide=function(silent=false){const selection=guideSelection(),plan=buildProfileGuidePlan(selection),levelError=profileGuideLevelError(selection);if(levelError||!plan.roles.length||!plan.formats.length||!plan.queries.length){refreshProfileGuide();if(!silent)toast(levelError||'Complete the profile guide before applying suggestions',true);return false}mergeGuideTerms('pfRolePhrases',plan.roles);mergeGuideTerms('pfFormatPhrases',plan.formats);mergeGuideTerms('pfSearchPhrases',plan.queries);$('pfGermanMax').value=['a1','a2'].includes($('pfGuideGerman').value)?'a2':$('pfGuideGerman').value==='b2'?'b2':'b1';$('pfContentDe').checked=selection.languages.includes('de');$('pfContentEn').checked=selection.languages.includes('en');$('pfContentMixed').checked=selection.languages.length>1;let keywords;try{keywords=JSON.parse($('pfKeywords').value||'{}')}catch(_){keywords={}}keywords.language={[`english_${$('pfGuideEnglish').value}`]:0};$('pfKeywords').value=JSON.stringify(keywords,null,2);guideDirty=false;refreshProfileGuide();if(!silent)toast('Suggestions added; existing custom terms were preserved');return true}; - function hydrateProfileGuide(profile){if(!$('pfGuidePreset'))return;const keywords=profile?.keywords||{},titleTerms=Object.keys(keywords.title||{}).map(term=>term.toLowerCase()),formatTerms=Object.keys(keywords.format||{}).map(term=>term.toLowerCase());Object.entries(GUIDE_ROLES).forEach(([key,role])=>{$(`pfGuideRole-${key}`).checked=role.aliases.some(alias=>titleTerms.includes(alias.toLowerCase()))});Object.entries(GUIDE_FORMATS).forEach(([key,format])=>{$(`pfGuideFormat-${key}`).checked=format.terms.some(term=>formatTerms.includes(term.toLowerCase()))});$('pfGuideGerman').value=profile?.current_german_level||'a2_b1';const englishKey=Object.keys(keywords.language||{}).find(term=>term.startsWith('english_'));$('pfGuideEnglish').value=englishKey?.slice(8)||'b1';$('pfGuideLanguageDe').checked=(profile?.content_languages||['de','en']).includes('de');$('pfGuideLanguageEn').checked=(profile?.content_languages||['de','en']).includes('en');$('pfGuideEnrolled').checked=formatTerms.some(term=>['werkstudent','working student'].includes(term));$('pfGuideAvailability').value=Object.keys(keywords.allowlist||{}).some(term=>/nachmittags|afternoon|ab 14/i.test(term))?'afternoon':'any';$('pfGuideHours').value='';$('pfGuidePreset').value='';refreshProfileGuide()} + function hydrateProfileGuide(profile){if(!$('pfGuidePreset'))return;const keywords=profile?.keywords||{},titleTerms=Object.keys(keywords.title||{}).map(term=>term.toLowerCase()),formatTerms=Object.keys(keywords.format||{}).map(term=>term.toLowerCase());Object.entries(GUIDE_ROLES).forEach(([key,role])=>{$(`pfGuideRole-${key}`).checked=role.aliases.some(alias=>titleTerms.includes(alias.toLowerCase()))});Object.entries(GUIDE_FORMATS).forEach(([key,format])=>{$(`pfGuideFormat-${key}`).checked=format.terms.some(term=>formatTerms.includes(term.toLowerCase()))});$('pfGuideRoleLevel').value=profile?.role_level||'any';$('pfGuideGerman').value=profile?.current_german_level||'a2_b1';const englishKey=Object.keys(keywords.language||{}).find(term=>term.startsWith('english_'));$('pfGuideEnglish').value=profile?.current_english_level||englishKey?.slice(8)||'b1';$('pfGuideLanguageDe').checked=(profile?.content_languages||['de','en']).includes('de');$('pfGuideLanguageEn').checked=(profile?.content_languages||['de','en']).includes('en');$('pfGuideEnrolled').checked=(profile?.role_level==='student')||formatTerms.some(term=>['werkstudent','working student'].includes(term));$('pfGuideAvailability').value=profile?.availability||'any';$('pfGuideHours').value=profile?.preferred_weekly_hours??'';$('pfGuidePreset').value='';guideDirty=false;refreshProfileGuide()} function install(){const nav=document.querySelector('.nav'),main=document.querySelector('.main');if(!nav||!main||$('profiles'))return;const b=document.createElement('button');b.dataset.tab='profiles';b.textContent='Profiles';nav.insertBefore(b,nav.querySelector('[data-tab="search"]')||null);const s=document.createElement('section');s.id='profiles';s.className='section';s.innerHTML=`

Search Profiles

Each profile has independent scoring, language thresholds and learned preferences.
`;main.appendChild(s);b.addEventListener('click',()=>{document.querySelectorAll('.section').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.nav button').forEach(x=>x.classList.remove('active'));s.classList.add('active');b.classList.add('active');if($('pageTitle'))$('pageTitle').textContent='Profiles';loadProfiles()}); const modal=document.createElement('div');modal.id='profileModal';modal.className='profile-modal';modal.innerHTML=`

Search profile

-
Guided profile builder
-
Choose your real target roles, working arrangements and language ability; the guide creates ready-to-use job-board searches.
-
+
+
Profile essentials
+
Choose the target level, roles, working arrangements and language ability. Saving also adds the generated search suggestions without removing custom terms.
+
${Object.entries(GUIDE_ROLES).map(([key,role])=>``).join('')}
${Object.entries(GUIDE_FORMATS).map(([key,format])=>``).join('')}
-
+
-
-
-
-
+
+
Advanced matching settings
+
-
+
Used by the “Profile preference” filter; ads are never deleted.
One phrase per line. These are provider queries, not scoring rules.
One role per line. Used for role matching and bilingual expansion.
One format per line. Part-time hours are also recognized automatically.
Sections: search, title, format, skill, allowlist, blocklist and legacy negative. Allowlist weights are positive boosts; every blocklist match is a hard exclusion.
-
`;document.body.appendChild(modal);modal.querySelectorAll('#pfProfileGuide input,#pfProfileGuide select').forEach(input=>input.addEventListener('change',refreshProfileGuide)); +
`;document.body.appendChild(modal);modal.querySelectorAll('#pfProfileGuide input,#pfProfileGuide select').forEach(input=>input.addEventListener('change',()=>{guideDirty=true;refreshProfileGuide()})); } - window.loadProfiles=async function(){const d=await api('/api/profiles');profiles=d.profiles;const grid=$('profileGrid');if(grid)grid.innerHTML=profiles.map(p=>`
${esc(p.name)}
${p.enabled?'Enabled':'Disabled'}${p.is_default?'Default':''}${esc(p.current_german_level.replace('_',' → ').toUpperCase())}
${esc(p.target_location)} · min ${p.min_score} · language min ${p.min_language_score} · language weight ${p.language_weight}%
${Object.keys(p.keywords?.search||{}).length} search phrases · ${Object.keys(p.keywords?.allowlist||{}).length} allowlist · ${Object.keys(p.keywords?.blocklist||{}).length} blacklist
${p.is_default?'':``}
`).join('');return profiles}; + window.loadProfiles=async function(){const d=await api('/api/profiles');profiles=d.profiles;const grid=$('profileGrid');if(grid)grid.innerHTML=profiles.map(p=>`
${esc(p.name)}
${p.enabled?'Enabled':'Disabled'}${p.is_default?'Default':''}${esc(ROLE_LEVEL_LABELS[p.role_level]||ROLE_LEVEL_LABELS.any)}DE ${esc(p.current_german_level.replace('_',' → ').toUpperCase())}EN ${esc((p.current_english_level||'b1').toUpperCase())}${p.preferred_weekly_hours?`${p.preferred_weekly_hours}h/week`:''}
${esc(p.target_location)} · min ${p.min_score} · language min ${p.min_language_score} · language weight ${p.language_weight}%
${Object.keys(p.keywords?.search||{}).length} search phrases · ${Object.keys(p.keywords?.allowlist||{}).length} allowlist · ${Object.keys(p.keywords?.blocklist||{}).length} blacklist
${p.is_default?'':``}
`).join('');return profiles}; - window.openProfileEditor=function(id){const p=id?profiles.find(x=>x.id===id):null,content=p?.content_languages||['de','en','mixed'];$('pfId').value=p?.id||'';$('profileModalTitle').textContent=p?'Edit search profile':'New search profile';$('pfName').value=p?.name||'';$('pfSlug').value=p?.slug||'';$('pfLocation').value=p?.target_location||'Berlin';$('pfLocations').value=(p?.location_terms||['berlin']).join(',');$('pfMin').value=p?.min_score??35;$('pfLangMin').value=p?.min_language_score??40;$('pfLangWeight').value=p?.language_weight??35;$('pfGerman').value=p?.current_german_level||'a2_b1';$('pfGermanMax').value=p?.max_german_requirement||'b1';$('pfContentDe').checked=content.includes('de');$('pfContentEn').checked=content.includes('en');$('pfContentMixed').checked=content.includes('mixed');$('pfContentUnknown').checked=content.includes('unknown');$('pfKeywords').value=JSON.stringify(p?.keywords||{search:{},title:{},format:{},skill:{},allowlist:{},blocklist:{},negative:{}},null,2);$('pfSearchPhrases').value=Object.keys(p?.keywords?.search||{}).join('\n');$('pfRolePhrases').value=Object.keys(p?.keywords?.title||{}).join('\n');$('pfFormatPhrases').value=Object.keys(p?.keywords?.format||{}).join('\n');$('pfEnabled').checked=p?.enabled??true;$('pfDefault').checked=p?.is_default??false;$('pfStretch').checked=p?.show_b2_stretch??true;$('pfHideHeavy').checked=p?.hide_german_heavy??true;$('pfGrowth').checked=p?.prefer_german_growth??true;hydrateProfileGuide(p);$('profileModal').classList.add('open')}; + window.openProfileEditor=function(id){const p=id?profiles.find(x=>x.id===id):null,content=p?.content_languages||['de','en','mixed'];$('pfId').value=p?.id||'';$('profileModalTitle').textContent=p?'Edit search profile':'New search profile';$('profileSaveStatus').textContent='';$('pfName').value=p?.name||'';$('pfSlug').value=p?.slug||'';$('pfLocation').value=p?.target_location||'Berlin';$('pfLocations').value=(p?.location_terms||['berlin']).join(',');$('pfMin').value=p?.min_score??35;$('pfLangMin').value=p?.min_language_score??40;$('pfLangWeight').value=p?.language_weight??35;$('pfGermanMax').value=p?.max_german_requirement||'b1';$('pfContentDe').checked=content.includes('de');$('pfContentEn').checked=content.includes('en');$('pfContentMixed').checked=content.includes('mixed');$('pfContentUnknown').checked=content.includes('unknown');$('pfKeywords').value=JSON.stringify(p?.keywords||{search:{},title:{},format:{},skill:{},allowlist:{},blocklist:{},negative:{}},null,2);$('pfSearchPhrases').value=Object.keys(p?.keywords?.search||{}).join('\n');$('pfRolePhrases').value=Object.keys(p?.keywords?.title||{}).join('\n');$('pfFormatPhrases').value=Object.keys(p?.keywords?.format||{}).join('\n');$('pfEnabled').checked=p?.enabled??true;$('pfDefault').checked=p?.is_default??false;$('pfStretch').checked=p?.show_b2_stretch??true;$('pfHideHeavy').checked=p?.hide_german_heavy??true;$('pfGrowth').checked=p?.prefer_german_growth??true;$('pfAdvanced').open=false;hydrateProfileGuide(p);$('profileModal').classList.add('open')}; window.closeProfileEditor=()=>$('profileModal').classList.remove('open'); - window.saveProfileEditor=async function(){let keywords;try{keywords=JSON.parse($('pfKeywords').value||'{}')}catch(e){$('profileSaveStatus').textContent='Invalid keyword JSON';$('profileSaveStatus').className='status error';return}for(const [section,id,weight] of [['search','pfSearchPhrases',0],['title','pfRolePhrases',28],['format','pfFormatPhrases',16]]){const previous=keywords[section]||{};keywords[section]=Object.fromEntries($(id).value.split('\n').map(x=>x.trim()).filter(Boolean).map(term=>[term,Object.prototype.hasOwnProperty.call(previous,term)?previous[term]:weight]))}const id=+$('pfId').value,content_languages=[['pfContentDe','de'],['pfContentEn','en'],['pfContentMixed','mixed'],['pfContentUnknown','unknown']].filter(x=>$(x[0]).checked).map(x=>x[1]);const payload={name:$('pfName').value.trim(),slug:$('pfSlug').value.trim()||$('pfName').value.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-'),enabled:$('pfEnabled').checked,is_default:$('pfDefault').checked,target_location:$('pfLocation').value.trim()||'Berlin',location_terms:$('pfLocations').value.split(',').map(x=>x.trim().toLowerCase()).filter(Boolean),min_score:+$('pfMin').value||0,min_language_score:+$('pfLangMin').value||0,language_weight:+$('pfLangWeight').value||0,current_german_level:$('pfGerman').value,max_german_requirement:$('pfGermanMax').value,content_languages,show_b2_stretch:$('pfStretch').checked,hide_german_heavy:$('pfHideHeavy').checked,prefer_german_growth:$('pfGrowth').checked,keywords};try{await api(id?`/api/profiles/${id}`:'/api/profiles',{method:id?'PUT':'POST',body:JSON.stringify(payload)});toast('Profile saved');closeProfileEditor();await loadProfiles();if(window.loadReviewProfiles)await window.loadReviewProfiles()}catch(e){$('profileSaveStatus').textContent=e.message;$('profileSaveStatus').className='status error'}}; + window.saveProfileEditor=async function(){const selection=guideSelection(),levelError=profileGuideLevelError(selection);if(levelError){$('profileSaveStatus').textContent=levelError;$('profileSaveStatus').className='status error';return}if(guideDirty)applyProfileGuide(true);let keywords;try{keywords=JSON.parse($('pfKeywords').value||'{}')}catch(e){$('profileSaveStatus').textContent='Invalid keyword JSON';$('profileSaveStatus').className='status error';$('pfAdvanced').open=true;return}for(const [section,id,weight] of [['search','pfSearchPhrases',0],['title','pfRolePhrases',28],['format','pfFormatPhrases',16]]){const previous=keywords[section]||{};keywords[section]=Object.fromEntries($(id).value.split('\n').map(x=>x.trim()).filter(Boolean).map(term=>[term,Object.prototype.hasOwnProperty.call(previous,term)?previous[term]:weight]))}const id=+$('pfId').value,content_languages=[['pfContentDe','de'],['pfContentEn','en'],['pfContentMixed','mixed'],['pfContentUnknown','unknown']].filter(x=>$(x[0]).checked).map(x=>x[1]),hours=$('pfGuideHours').value?+$('pfGuideHours').value:null;const payload={name:$('pfName').value.trim(),slug:$('pfSlug').value.trim()||$('pfName').value.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-'),enabled:$('pfEnabled').checked,is_default:$('pfDefault').checked,target_location:$('pfLocation').value.trim()||'Berlin',location_terms:$('pfLocations').value.split(',').map(x=>x.trim().toLowerCase()).filter(Boolean),min_score:+$('pfMin').value||0,min_language_score:+$('pfLangMin').value||0,language_weight:+$('pfLangWeight').value||0,current_german_level:$('pfGuideGerman').value,current_english_level:$('pfGuideEnglish').value,max_german_requirement:$('pfGermanMax').value,preferred_weekly_hours:hours,availability:$('pfGuideAvailability').value,role_level:$('pfGuideRoleLevel').value,content_languages,show_b2_stretch:$('pfStretch').checked,hide_german_heavy:$('pfHideHeavy').checked,prefer_german_growth:$('pfGrowth').checked,keywords};try{await api(id?`/api/profiles/${id}`:'/api/profiles',{method:id?'PUT':'POST',body:JSON.stringify(payload)});toast('Profile saved');closeProfileEditor();await loadProfiles();if(window.loadReviewProfiles)await window.loadReviewProfiles()}catch(e){$('profileSaveStatus').textContent=e.message;$('profileSaveStatus').className='status error'}}; window.removeProfile=async id=>{if(!confirm('Delete this search profile and its profile-specific scores?'))return;try{await api(`/api/profiles/${id}`,{method:'DELETE'});await loadProfiles();toast('Profile deleted')}catch(e){toast(e.message,true)}}; window.activateProfile=async id=>{window.activeProfileId=id;try{localStorage.setItem('jobtrack-profile',String(id))}catch(e){};const sel=$('reviewProfile');if(sel)sel.value=String(id);toast('Review profile changed');if(window.loadReviewJobs)await window.loadReviewJobs()}; install();loadProfiles().catch(()=>{}); diff --git a/app/profile_store.py b/app/profile_store.py index bb87138..d875eb8 100644 --- a/app/profile_store.py +++ b/app/profile_store.py @@ -19,7 +19,11 @@ min_language_score INTEGER NOT NULL DEFAULT 40, language_weight INTEGER NOT NULL DEFAULT 35, current_german_level TEXT NOT NULL DEFAULT 'a2_b1', + current_english_level TEXT NOT NULL DEFAULT 'b1', max_german_requirement TEXT NOT NULL DEFAULT 'b1', + preferred_weekly_hours INTEGER, + availability TEXT NOT NULL DEFAULT 'any', + role_level TEXT NOT NULL DEFAULT 'any', show_b2_stretch INTEGER NOT NULL DEFAULT 1, hide_german_heavy INTEGER NOT NULL DEFAULT 1, prefer_german_growth INTEGER NOT NULL DEFAULT 1, @@ -197,7 +201,7 @@ def _backfill_role_relevance(con) -> None: rows = con.execute( """SELECT s.job_key,s.profile_id,j.source,j.title,j.company,j.location,j.url,j.description, - j.created_at,j.remote,p.keywords_json + j.created_at,j.remote,p.keywords_json,p.role_level FROM job_profile_scores s JOIN jobs j ON j.job_key=s.job_key JOIN search_profiles p ON p.id=s.profile_id""" @@ -215,7 +219,9 @@ def _backfill_role_relevance(con) -> None: created_at=row["created_at"], remote=bool(row["remote"]), ) - relevant = row["source"] == "Manual" or assess_role_relevance(job, keywords).relevant + relevant = ( + row["source"] == "Manual" or assess_role_relevance(job, keywords, role_level=row["role_level"]).relevant + ) con.execute( "UPDATE job_profile_scores SET role_relevant=? WHERE job_key=? AND profile_id=?", (int(relevant), row["job_key"], row["profile_id"]), @@ -236,6 +242,8 @@ def _migrate_profile_ownership(con) -> None: target_location TEXT NOT NULL DEFAULT 'Berlin',location_terms_json TEXT NOT NULL DEFAULT '[]', min_score INTEGER NOT NULL DEFAULT 35,min_language_score INTEGER NOT NULL DEFAULT 40, language_weight INTEGER NOT NULL DEFAULT 35,current_german_level TEXT NOT NULL DEFAULT 'a2_b1', + current_english_level TEXT NOT NULL DEFAULT 'b1',preferred_weekly_hours INTEGER, + availability TEXT NOT NULL DEFAULT 'any',role_level TEXT NOT NULL DEFAULT 'any', max_german_requirement TEXT NOT NULL DEFAULT 'b1',show_b2_stretch INTEGER NOT NULL DEFAULT 1, hide_german_heavy INTEGER NOT NULL DEFAULT 1,prefer_german_growth INTEGER NOT NULL DEFAULT 1, content_languages_json TEXT NOT NULL DEFAULT '[\"de\",\"en\",\"mixed\"]', @@ -246,11 +254,12 @@ def _migrate_profile_ownership(con) -> None: con.execute( """INSERT INTO search_profiles_v18 (id,user_id,name,slug,enabled,is_default,target_location,location_terms_json,min_score, - min_language_score,language_weight,current_german_level,max_german_requirement, + min_language_score,language_weight,current_german_level,current_english_level, + preferred_weekly_hours,availability,role_level,max_german_requirement, show_b2_stretch,hide_german_heavy,prefer_german_growth,content_languages_json, keywords_json,created_at,updated_at) SELECT id,NULL,name,slug,enabled,is_default,target_location,location_terms_json,min_score, - min_language_score,language_weight,current_german_level,max_german_requirement, + min_language_score,language_weight,current_german_level,'b1',NULL,'any','any',max_german_requirement, show_b2_stretch,hide_german_heavy,prefer_german_growth, COALESCE(content_languages_json,'[\"de\",\"en\",\"mixed\"]'),keywords_json,created_at,updated_at FROM search_profiles""" @@ -279,6 +288,28 @@ def ensure_profile_schema(user_id: int | None = None) -> None: con.execute( 'ALTER TABLE search_profiles ADD COLUMN content_languages_json TEXT NOT NULL DEFAULT \'["de","en","mixed"]\'' ) + english_column_added = "current_english_level" not in columns + if english_column_added: + con.execute("ALTER TABLE search_profiles ADD COLUMN current_english_level TEXT NOT NULL DEFAULT 'b1'") + if "preferred_weekly_hours" not in columns: + con.execute("ALTER TABLE search_profiles ADD COLUMN preferred_weekly_hours INTEGER") + if "availability" not in columns: + con.execute("ALTER TABLE search_profiles ADD COLUMN availability TEXT NOT NULL DEFAULT 'any'") + if "role_level" not in columns: + con.execute("ALTER TABLE search_profiles ADD COLUMN role_level TEXT NOT NULL DEFAULT 'any'") + if english_column_added: + for row in con.execute("SELECT id,keywords_json FROM search_profiles").fetchall(): + keywords = json.loads(row["keywords_json"] or "{}") + legacy = next( + ( + term.removeprefix("english_") + for term in (keywords.get("language") or {}) + if term.startswith("english_") + ), + None, + ) + if legacy in {"a2", "b1", "b2", "c1", "c2"}: + con.execute("UPDATE search_profiles SET current_english_level=? WHERE id=?", (legacy, row["id"])) score_columns = {row[1] for row in con.execute("PRAGMA table_info(job_profile_scores)").fetchall()} if "role_relevant" not in score_columns: con.execute("ALTER TABLE job_profile_scores ADD COLUMN role_relevant INTEGER NOT NULL DEFAULT 0") @@ -385,6 +416,9 @@ def _row_to_profile(row) -> dict[str, Any]: p["show_b2_stretch"] = bool(p["show_b2_stretch"]) p["hide_german_heavy"] = bool(p["hide_german_heavy"]) p["prefer_german_growth"] = bool(p["prefer_german_growth"]) + p["preferred_weekly_hours"] = ( + int(p["preferred_weekly_hours"]) if p.get("preferred_weekly_hours") is not None else None + ) p["content_languages"] = json.loads(p.pop("content_languages_json") or "[]") p["location_terms"] = json.loads(p.pop("location_terms_json") or "[]") p["keywords"] = json.loads(p.pop("keywords_json") or "{}") @@ -433,7 +467,11 @@ def save_profile(data: dict[str, Any], profile_id: int | None = None, user_id: i "min_language_score": int(data.get("min_language_score", 40)), "language_weight": int(data.get("language_weight", 35)), "current_german_level": data.get("current_german_level", "a2_b1"), + "current_english_level": data.get("current_english_level", "b1"), "max_german_requirement": data.get("max_german_requirement", "b1"), + "preferred_weekly_hours": data.get("preferred_weekly_hours"), + "availability": data.get("availability", "any"), + "role_level": data.get("role_level", "any"), "show_b2_stretch": int(bool(data.get("show_b2_stretch", True))), "hide_german_heavy": int(bool(data.get("hide_german_heavy", True))), "prefer_german_growth": int(bool(data.get("prefer_german_growth", True))), @@ -445,15 +483,15 @@ def save_profile(data: dict[str, Any], profile_id: int | None = None, user_id: i con.execute("UPDATE search_profiles SET is_default=0 WHERE user_id IS ?", (user_id,)) if profile_id: con.execute( - """UPDATE search_profiles SET name=:name,slug=:slug,enabled=:enabled,is_default=:is_default,target_location=:target_location,location_terms_json=:location_terms_json,min_score=:min_score,min_language_score=:min_language_score,language_weight=:language_weight,current_german_level=:current_german_level,max_german_requirement=:max_german_requirement,show_b2_stretch=:show_b2_stretch,hide_german_heavy=:hide_german_heavy,prefer_german_growth=:prefer_german_growth,content_languages_json=:content_languages_json,keywords_json=:keywords_json,updated_at=:updated_at WHERE id=:id AND user_id IS :user_id""", + """UPDATE search_profiles SET name=:name,slug=:slug,enabled=:enabled,is_default=:is_default,target_location=:target_location,location_terms_json=:location_terms_json,min_score=:min_score,min_language_score=:min_language_score,language_weight=:language_weight,current_german_level=:current_german_level,current_english_level=:current_english_level,max_german_requirement=:max_german_requirement,preferred_weekly_hours=:preferred_weekly_hours,availability=:availability,role_level=:role_level,show_b2_stretch=:show_b2_stretch,hide_german_heavy=:hide_german_heavy,prefer_german_growth=:prefer_german_growth,content_languages_json=:content_languages_json,keywords_json=:keywords_json,updated_at=:updated_at WHERE id=:id AND user_id IS :user_id""", {**fields, "updated_at": now, "id": profile_id, "user_id": user_id}, ) if con.execute("SELECT changes()").fetchone()[0] == 0: raise ValueError("Profile not found") return profile_id cur = con.execute( - """INSERT INTO search_profiles(user_id,name,slug,enabled,is_default,target_location,location_terms_json,min_score,min_language_score,language_weight,current_german_level,max_german_requirement,show_b2_stretch,hide_german_heavy,prefer_german_growth,content_languages_json,keywords_json,created_at,updated_at) - VALUES(:user_id,:name,:slug,:enabled,:is_default,:target_location,:location_terms_json,:min_score,:min_language_score,:language_weight,:current_german_level,:max_german_requirement,:show_b2_stretch,:hide_german_heavy,:prefer_german_growth,:content_languages_json,:keywords_json,:created_at,:updated_at)""", + """INSERT INTO search_profiles(user_id,name,slug,enabled,is_default,target_location,location_terms_json,min_score,min_language_score,language_weight,current_german_level,current_english_level,max_german_requirement,preferred_weekly_hours,availability,role_level,show_b2_stretch,hide_german_heavy,prefer_german_growth,content_languages_json,keywords_json,created_at,updated_at) + VALUES(:user_id,:name,:slug,:enabled,:is_default,:target_location,:location_terms_json,:min_score,:min_language_score,:language_weight,:current_german_level,:current_english_level,:max_german_requirement,:preferred_weekly_hours,:availability,:role_level,:show_b2_stretch,:hide_german_heavy,:prefer_german_growth,:content_languages_json,:keywords_json,:created_at,:updated_at)""", {**fields, "user_id": user_id, "created_at": now, "updated_at": now}, ) return int(cur.lastrowid) @@ -469,6 +507,20 @@ def delete_profile(profile_id: int, user_id: int | None = None) -> None: return if row["is_default"]: raise ValueError("Default profile cannot be deleted") + has_search_jobs = con.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='search_jobs'" + ).fetchone() + linked = ( + con.execute( + "SELECT name FROM search_jobs WHERE profile_id=? AND user_id IS ? ORDER BY name", + (profile_id, user_id), + ).fetchall() + if has_search_jobs + else [] + ) + if linked: + names = ", ".join(item["name"] for item in linked) + raise ValueError(f"Profile is used by search jobs: {names}. Reassign or delete them first.") con.execute("DELETE FROM search_profiles WHERE id=? AND user_id IS ?", (profile_id, user_id)) diff --git a/app/ranker.py b/app/ranker.py index ba77a94..2b94b40 100644 --- a/app/ranker.py +++ b/app/ranker.py @@ -40,6 +40,50 @@ class RoleAssessment: reasons: tuple[str, ...] +ROLE_LEVEL_SIGNALS = { + "technician": ( + "technician", + "techniker", + "qualitätsprüfer", + "qualitaetspruefer", + "prüftechniker", + "inspector", + "fachkraft", + "mitarbeiter qualität", + "mitarbeiter qualitaet", + ), + "engineer": ("engineer", "engineering", "ingenieur"), + "student": ("werkstudent", "working student", "student assistant", "studentische hilfskraft"), + "manager": ("manager", "teamleiter", "leiter", "head of", "director"), +} + + +def _role_level_conflict(title: str, role_level: str) -> str | None: + level = str(role_level or "any").lower() + if level == "any": + return None + hits = { + key: any(contains_phrase(title, signal) for signal in signals) for key, signals in ROLE_LEVEL_SIGNALS.items() + } + if level == "student" and not hits["student"]: + return "student role required" + if level == "technician": + if hits["student"]: + return "student role does not match technician level" + if hits["manager"]: + return "management role does not match technician level" + if hits["engineer"] and not hits["technician"]: + return "engineering role does not match technician level" + if level == "engineer": + if hits["student"]: + return "student role does not match engineering level" + if hits["manager"]: + return "management role does not match engineering level" + if hits["technician"] and not hits["engineer"]: + return "technician role does not match engineering level" + return None + + def classify_match_tier( *, role_relevant: bool, @@ -80,6 +124,7 @@ def assess_role_relevance( keywords: dict, intent_terms=(), restrict_to_intent: bool = False, + role_level: str = "any", ) -> RoleAssessment: """Require occupational evidence before softer signals can influence ranking. @@ -104,6 +149,10 @@ def assess_role_relevance( industrial_hits = _matching_terms(body, INDUSTRIAL_DOMAIN_SIGNALS) reasons: list[str] = [] + level_conflict = _role_level_conflict(title, role_level) + if level_conflict: + reasons.append(f"role level conflict: {level_conflict}") + return RoleAssessment(False, "level_conflict", tuple(requested), tuple(title_families), tuple(reasons)) if title_hits: reasons.append(f"role title evidence: {', '.join(title_hits[:3])}") if title_families: diff --git a/app/search_job_service.py b/app/search_job_service.py index 54f43d2..6f7e997 100644 --- a/app/search_job_service.py +++ b/app/search_job_service.py @@ -201,6 +201,7 @@ async def run_search_job(search_job_id: int) -> dict: keyword_rules, search_terms, restrict_to_intent=custom_role_intent, + role_level=profile.get("role_level", "any"), ) job.role_relevant = role.relevant job.reasons.extend(reason for reason in role.reasons if reason not in job.reasons) diff --git a/app/service.py b/app/service.py index c765350..a7dbbc1 100644 --- a/app/service.py +++ b/app/service.py @@ -107,7 +107,7 @@ async def run_search() -> dict: continue location_terms = profile.get("location_terms") or cfg["location_terms"] job.score, job.reasons = score_job(job, keywords, location_terms) - role = assess_role_relevance(job, keywords) + role = assess_role_relevance(job, keywords, role_level=profile.get("role_level", "any")) job.role_relevant = role.relevant job.reasons.extend(reason for reason in role.reasons if reason not in job.reasons) employment_ok, _employment_label, employment_reasons = assess_employment_fit(job, profile, strict=False) diff --git a/app/ui-shell.js b/app/ui-shell.js index abebaab..42c2ec7 100644 --- a/app/ui-shell.js +++ b/app/ui-shell.js @@ -61,21 +61,23 @@ 'One phrase per line. Bert runs unqualified bilingual role queries before schedule-specific variants so capped providers cover every role.':'Her satıra bir ifade. Bert, sınırlı sorgu kabul eden kaynakların her rolü tarayabilmesi için önce çalışma saati eklenmemiş iki dilli rol sorgularını çalıştırır.', 'One role per line. Used for role matching and bilingual expansion.':'Her satıra bir rol. Rol eşleştirme ve iki dilli genişletme için kullanılır.', 'One format per line. Part-time hours are also recognized automatically.':'Her satıra bir çalışma biçimi. Yarı zamanlı saatler otomatik tanınır.', - 'Guided profile builder':'Rehberli profil oluşturucu','Choose your real target roles, working arrangements and language ability; the guide creates ready-to-use job-board searches.':'Gerçek hedef rollerinizi, çalışma biçiminizi ve dil seviyenizi seçin; kılavuz ilan siteleri için hazır arama ifadeleri oluşturur.', + 'Profile essentials':'Temel profil ayarları','Choose the target level, roles, working arrangements and language ability. Saving also adds the generated search suggestions without removing custom terms.':'Hedef seviyeyi, rolleri, çalışma biçimlerini ve dil yeteneğini seçin. Kaydetme işlemi özel terimleri silmeden oluşturulan arama önerilerini ekler.', + 'Advanced matching settings':'Gelişmiş eşleştirme ayarları','Profile name':'Profil adı','Target role level':'Hedef rol seviyesi','Any suitable level':'Uygun herhangi bir seviye','Technician / inspection staff':'Teknisyen / kalite kontrol personeli','Engineer':'Mühendis','Working student':'Werkstudent', 'Start with a profile example':'Örnek profille başlayın','Choose an example':'Örnek seçin','Example actions':'Örnek işlemleri','Use profile example':'Örnek profili kullan', 'Technical support · part-time':'Teknik destek · yarı zamanlı','Engineering · full-time':'Mühendislik · tam zamanlı','Technical office · minijob':'Teknik ofis · minijob', '1. Which positions match your experience?':'1. Deneyiminize hangi pozisyonlar uyuyor?','2. Which working arrangements are acceptable?':'2. Hangi çalışma biçimlerini kabul ediyorsunuz?', - 'Quality / inspection':'Kalite / kontrol','Production / manufacturing':'Üretim / imalat','Production planning':'Üretim planlama','Process engineering':'Proses mühendisliği', + 'Quality control / technician':'Kalite kontrol / teknisyen','Quality engineering':'Kalite mühendisliği','Production / manufacturing':'Üretim / imalat','Production planning':'Üretim planlama','Process engineering':'Proses mühendisliği', 'Technical office / administration':'Teknik ofis / idari işler','Procurement / purchasing':'Satın alma / tedarik','Logistics / supply chain':'Lojistik / tedarik zinciri', 'Full-time / Vollzeit':'Tam zamanlı / Vollzeit','Part-time / Teilzeit':'Yarı zamanlı / Teilzeit','Working student / Werkstudent':'Öğrenci işi / Werkstudent', 'Currently enrolled at a university':'Hâlen üniversite öğrencisiyim','Preferred weekly hours':'Tercih edilen haftalık saat','Availability':'Uygun çalışma zamanı', 'Any suitable time':'Uygun herhangi bir zaman','Afternoons / after 14:00':'Öğleden sonra / 14.00 sonrası','Flexible working hours':'Esnek çalışma saatleri', 'Current German ability':'Mevcut Almanca seviyeniz','Current English ability':'Mevcut İngilizce seviyeniz', '3. Which languages should job-board searches use?':'3. İlan sitelerinde hangi dillerde arama yapılsın?','German job titles':'Almanca pozisyon adları','English job titles':'İngilizce pozisyon adları', - '4. Preview provider-ready search phrases':'4. İlan sitelerine hazır arama ifadelerini önizleyin','Apply guide to profile':'Kılavuzu profile uygula','Check profile targeting':'Profil hedeflerini kontrol et', + '4. Preview provider-ready search phrases':'4. İlan sitelerine hazır arama ifadelerini önizleyin','Add suggestions now':'Önerileri şimdi ekle','Check profile targeting':'Profil hedeflerini kontrol et', 'Choose a role and working arrangement to preview search phrases.':'Arama ifadelerini görmek için rol ve çalışma biçimi seçin.', 'Choose at least one target role.':'En az bir hedef rol seçin.','Choose at least one eligible working arrangement.':'En az bir uygun çalışma biçimi seçin.', 'Choose German or English provider queries.':'Almanca veya İngilizce arama dili seçin.','Working-student searches require current university enrollment.':'Werkstudent aramaları için güncel üniversite öğrenciliği gerekir.', + 'Student level requires the Working student arrangement.':'Öğrenci seviyesi için Werkstudent çalışma biçimini seçin.','Quality engineering conflicts with technician level.':'Kalite mühendisliği teknisyen seviyesiyle çelişiyor.','Quality technician conflicts with engineering level.':'Kalite teknisyeni mühendislik seviyesiyle çelişiyor.', 'Create separate full-time and part-time profiles for more precise matches.':'Daha isabetli eşleşmeler için tam ve yarı zamanlı profilleri ayırın.', 'Some providers only run the first 6–8 queries; role families are interleaved automatically.':'Bazı kaynaklar yalnızca ilk 6–8 sorguyu çalıştırır; rol aileleri otomatik dengelenir.', 'New profile':'Yeni profil','Edit search profile':'Arama profilini düzenle','New search profile':'Yeni arama profili', diff --git a/tests/test_employment_filter.py b/tests/test_employment_filter.py index ac61f8a..2c77344 100644 --- a/tests/test_employment_filter.py +++ b/tests/test_employment_filter.py @@ -84,6 +84,25 @@ def test_mixed_full_and_part_time_profile_accepts_both_and_keeps_queries(): assert assess_employment_fit(job("Qualitätsprüfer Teilzeit"), mixed)[0] is True +def test_first_class_hours_and_availability_are_constraints_or_preferences(): + profile = { + "name": "Quality technician", + "slug": "quality-technician", + "preferred_weekly_hours": 20, + "availability": "afternoon", + "keywords": {"format": {"Vollzeit": 16, "Teilzeit": 16}}, + } + vacancy = job("Qualitätsprüfer", "Teilzeit, 30 Stunden pro Woche am Vormittag.") + + strict = assess_employment_fit(vacancy, profile, strict=True) + preferred = assess_employment_fit(vacancy, profile, strict=False) + + assert strict[0] is False + assert preferred[0] is True + assert any("exceeds preferred 20" in reason for reason in preferred[2]) + assert "employment mismatch: afternoon availability not confirmed" in preferred[2] + + def test_mixed_hours_profile_does_not_admit_student_only_jobs_without_enrollment(): mixed = { "name": "Quality engineering / Full-time and part-time", diff --git a/tests/test_profile_guide.py b/tests/test_profile_guide.py index 445c810..4c768c1 100644 --- a/tests/test_profile_guide.py +++ b/tests/test_profile_guide.py @@ -27,7 +27,7 @@ def test_profile_guide_builds_provider_queries_and_checks_targeting_before_apply assert "window.profileGuideBuildPlan=buildProfileGuidePlan" in text assert 'id="pfGuideQueryPreview"' in text assert 'id="pfGuideChecks"' in text - assert "Apply guide to profile" in text + assert "Add suggestions now" in text assert "keywords.language=" in text @@ -38,14 +38,39 @@ def test_profile_guide_covers_german_and_english_proficiency(): assert 'id="pfGuideLanguageDe"' in text assert 'id="pfGuideLanguageEn"' in text assert 'value="b2">B2' in text + assert 'value="c2">C2' in text + + +def test_profile_editor_persists_essentials_and_preserves_custom_terms(): + text = Path("app/profile-ui.js").read_text(encoding="utf-8") + for field in ( + "current_english_level", + "preferred_weekly_hours", + "availability", + "role_level", + 'id="pfGuideRoleLevel"', + 'id="pfAdvanced"', + ): + assert field in text + assert "mergeGuideTerms" in text + assert "existing custom terms were preserved" in text + assert "if(guideDirty)applyProfileGuide(true)" in text + + +def test_quality_guide_distinguishes_technician_and_engineering_roles(): + text = Path("app/profile-ui.js").read_text(encoding="utf-8") + assert "quality_technician" in text + assert "quality_engineering" in text + assert "Quality Technician" not in text # aliases stay normalized/lowercase + assert "Qualitätsprüfer" in text def test_turkish_translations_cover_guided_profile_workflow(): text = Path("app/ui-shell.js").read_text(encoding="utf-8") - assert "Rehberli profil oluşturucu" in text + assert "Temel profil ayarları" in text assert "Mevcut İngilizce seviyeniz" in text assert "Öğleden sonra / 14.00 sonrası" in text - assert "Kılavuzu profile uygula" in text + assert "Önerileri şimdi ekle" in text def test_both_search_paths_pass_profile_english_ability_into_matching(): diff --git a/tests/test_profiles.py b/tests/test_profiles.py index 51ed08b..872e443 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -1,12 +1,16 @@ from app import db from app.models import Job from app.profile_store import ( + delete_profile, ensure_profile_schema, + get_profile, get_job_for_profile, list_jobs_for_profile, list_profiles, + save_profile, upsert_profile_score, ) +from app.search_job_store import save_search_job from app.feedback_store import ensure_feedback_schema, record_feedback, apply_learned_penalty from app.positive_learning import record_positive_event, apply_positive_boost @@ -47,6 +51,77 @@ def test_default_profiles_are_seeded(tmp_path, monkeypatch): assert any(p["slug"] == "fulltime" for p in profiles) +def test_profile_essentials_are_persisted_as_first_class_fields(tmp_path, monkeypatch): + setup_db(tmp_path, monkeypatch) + profile_id = save_profile( + { + "name": "Technician quality", + "slug": "technician-quality", + "current_german_level": "a2", + "current_english_level": "c1", + "preferred_weekly_hours": 20, + "availability": "afternoon", + "role_level": "technician", + } + ) + + profile = get_profile(profile_id) + + assert profile["current_english_level"] == "c1" + assert profile["preferred_weekly_hours"] == 20 + assert profile["availability"] == "afternoon" + assert profile["role_level"] == "technician" + + +def test_profile_essentials_migrate_in_place_and_import_legacy_english(tmp_path, monkeypatch): + monkeypatch.setattr(db.settings, "database_path", str(tmp_path / "legacy-profile.db")) + db.init_db() + with db.connection() as con: + con.execute( + """CREATE TABLE search_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,name TEXT NOT NULL,slug TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1,is_default INTEGER NOT NULL DEFAULT 0, + target_location TEXT NOT NULL DEFAULT 'Berlin',location_terms_json TEXT NOT NULL DEFAULT '[]', + min_score INTEGER NOT NULL DEFAULT 35,min_language_score INTEGER NOT NULL DEFAULT 40, + language_weight INTEGER NOT NULL DEFAULT 35,current_german_level TEXT NOT NULL DEFAULT 'a2_b1', + max_german_requirement TEXT NOT NULL DEFAULT 'b1',show_b2_stretch INTEGER NOT NULL DEFAULT 1, + hide_german_heavy INTEGER NOT NULL DEFAULT 1,prefer_german_growth INTEGER NOT NULL DEFAULT 1, + content_languages_json TEXT NOT NULL DEFAULT '[\"de\",\"en\",\"mixed\"]', + keywords_json TEXT NOT NULL DEFAULT '{}',created_at TEXT NOT NULL,updated_at TEXT NOT NULL, + UNIQUE(user_id,name),UNIQUE(user_id,slug))""" + ) + con.execute( + """INSERT INTO search_profiles + (user_id,name,slug,keywords_json,created_at,updated_at) + VALUES(NULL,'MBA student','mba-student','{"language":{"english_c1":0}}','now','now')""" + ) + + ensure_profile_schema() + profile = list_profiles()[0] + + assert profile["current_english_level"] == "c1" + assert profile["preferred_weekly_hours"] is None + assert profile["availability"] == "any" + assert profile["role_level"] == "any" + + +def test_profile_delete_explains_linked_search_jobs(tmp_path, monkeypatch): + setup_db(tmp_path, monkeypatch) + profile_id = save_profile({"name": "Linked profile", "slug": "linked-profile"}) + save_search_job({"name": "Berlin quality daily", "profile_id": profile_id}) + + try: + delete_profile(profile_id) + except ValueError as exc: + message = str(exc) + else: + raise AssertionError("linked profile deletion should fail") + + assert "Berlin quality daily" in message + assert "Reassign or delete" in message + assert get_profile(profile_id) is not None + + def test_same_job_keeps_independent_profile_scores(tmp_path, monkeypatch): setup_db(tmp_path, monkeypatch) job = add_job() diff --git a/tests/test_role_relevance_v2.py b/tests/test_role_relevance_v2.py index 88644fd..787eac4 100644 --- a/tests/test_role_relevance_v2.py +++ b/tests/test_role_relevance_v2.py @@ -80,6 +80,29 @@ def test_software_quality_title_is_rejected_without_industrial_domain_evidence() assert assessment.confidence == "conflict" +def test_technician_profile_rejects_engineering_and_management_titles(): + technician_keywords = { + **KEYWORDS, + "title": {"quality technician": 35, "quality engineer": 35, "qualitätsprüfer": 35}, + } + technician = vacancy("technician", "Quality Technician", "Inspect manufactured components.") + engineer = vacancy("engineer-level", "Quality Engineer", "Quality systems in manufacturing.") + manager = vacancy("manager-level", "Quality Manager", "Lead the plant quality department.") + + assert assess_role_relevance(technician, technician_keywords, role_level="technician").relevant is True + assert assess_role_relevance(engineer, technician_keywords, role_level="technician").confidence == "level_conflict" + assert assess_role_relevance(manager, technician_keywords, role_level="technician").confidence == "level_conflict" + + +def test_student_profile_requires_student_title_signal(): + student = vacancy("student", "Working Student Supply Chain", "Support procurement operations.") + professional = vacancy("professional", "Supply Chain Specialist", "Manage procurement operations.") + keywords = {**KEYWORDS, "title": {"working student supply chain": 35, "supply chain specialist": 35}} + + assert assess_role_relevance(student, keywords, role_level="student").relevant is True + assert assess_role_relevance(professional, keywords, role_level="student").confidence == "level_conflict" + + def test_generic_production_and_hr_jobs_cannot_be_rescued_by_soft_signals(): production_worker = vacancy( "worker",