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

### Added

- Adds a public-vacancy matching diagnostic that explains the first failed gate and saves labelled regression examples.
- Adds per-Search Job precision/recall benchmarks plus source and query funnel analytics.
- Adds evidence-backed extraction of working hours, workload, student status, experience, language levels, and quality-role specialization.
- Adds bounded public-detail enrichment for sparse provider results with redirect-aware private-network protection.
- Adds conservative cross-source vacancy clustering while preserving every original source link and discovery query.
- Adds optional local multilingual semantic reranking through Ollama, disabled by default and limited to eligible vacancies.
- 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.
Expand All @@ -16,6 +22,8 @@

### Fixed

- Prevents a single positive or negative feedback event from immediately distorting future scores.
- Keeps optional enrichment, semantic, and analytics failures from stopping deterministic searches.
- 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.
Expand All @@ -28,6 +36,8 @@

### Changed

- Requires two corroborating ordinary feedback examples before a learned rule affects ranking; interview and offer evidence remain immediately actionable.
- Prioritizes quality-technician queries for technician profiles and quality-engineering queries for engineering profiles.
- 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.
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ It supports separate user workspaces, scheduled searches, profile-specific rules
- German, English, mixed, and unknown job-ad language detection
- Per-search preferred or strict working-time handling for full-time, part-time, Werkstudent, and Minijob searches
- Bilingual role-first query planning, structural role relevance, and Strong / Match / Stretch result tiers
- Public vacancy diagnostics with saved precision/recall regression benchmarks
- Structured extraction of working hours, workload, student status, experience, language levels, and quality-role level
- Cross-source duplicate clustering with every original application link preserved
- Per-Search Job source and query funnel analytics
- Optional local multilingual semantic reranking through Ollama; deterministic eligibility gates remain authoritative
- Candidate Profiles with encrypted CV text
- Optional local Ollama context for CV analysis
- Application workspace with Kanban/list views, follow-up dates, contacts, activity history, and source conversion
Expand Down Expand Up @@ -61,6 +66,11 @@ The CV Match threshold becomes a hard gate only when the provider supplied a suf
Title-relevant vacancies with a short card/snippet remain visible as `Stretch` with a deferred-CV explanation instead
of being rejected for evidence the source did not provide.

Before deciding that a sparse vacancy has unknown working time, Bert makes a bounded attempt to load the public
vacancy page and extract `JobPosting` structured data or the visible description. Public URLs are validated on every
redirect and private/local network addresses are rejected. The extracted facts retain their supporting text so hours,
student requirements, language levels, experience, and quality-role level remain explainable.

Search Jobs default to **Prefer profile hours; keep stretch roles**. This keeps a strong technical vacancy visible when
it is full-time or its hours are missing, while labeling the constraint for review; it can still be notified when the
other configured thresholds pass. Select **Strictly exclude other/unknown hours** when the working arrangement is a
Expand Down Expand Up @@ -171,6 +181,25 @@ Part-time and working-student profiles always require a confirmed work type afte
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.

Open **Search Jobs → Why was a job missed?** to paste a public vacancy URL and replay blocklist, role, working-time,
language, learning, and fit decisions. Label representative URLs as **Should match** or **Should not match**, then run
the saved benchmark after profile or matcher changes. Bert reports precision, recall, and the first failing stage.
The same panel shows source and query funnels over recent runs. Treat `low yield` and `no results` as review signals;
Bert does not automatically disable a source based on a small sample.

Duplicate vacancies from different providers are clustered using canonical URL, normalized company/location, and
conservative title similarity. Bert keeps the richest description and exposes all original source links in the job
detail dialog.

Feedback learning is deliberately conservative: a dismissal, suitability decision, or ordinary application must be
corroborated by a second matching example before changing future scores. Interview and offer evidence can activate a
positive preference immediately. Learned adjustments remain profile-specific and capped.

Optional semantic reranking can be enabled under **Settings → Intelligence** when a local Ollama server and embedding
model such as `nomic-embed-text` are available. It reranks only vacancies that already passed deterministic role,
working-time, language, fit, and CV gates; it cannot make an ineligible vacancy eligible. The default semantic weight
is 15% and the feature is off by default, so no external AI service is required.

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
47 changes: 41 additions & 6 deletions app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def exclusive_database_access():
decision_at TEXT,
content_language TEXT NOT NULL DEFAULT 'unknown',
content_language_confidence REAL NOT NULL DEFAULT 0,
content_language_source TEXT NOT NULL DEFAULT 'detected'
content_language_source TEXT NOT NULL DEFAULT 'detected',
source_options_json TEXT NOT NULL DEFAULT '[]',
discovered_queries_json TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_jobs_score ON jobs(score DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_first_seen ON jobs(first_seen DESC);
Expand Down Expand Up @@ -335,6 +337,10 @@ def _init_db_unlocked() -> None:
con.execute("ALTER TABLE jobs ADD COLUMN content_language_confidence REAL NOT NULL DEFAULT 0")
if "content_language_source" not in job_columns:
con.execute("ALTER TABLE jobs ADD COLUMN content_language_source TEXT NOT NULL DEFAULT 'detected'")
if "source_options_json" not in job_columns:
con.execute("ALTER TABLE jobs ADD COLUMN source_options_json TEXT NOT NULL DEFAULT '[]'")
if "discovered_queries_json" not in job_columns:
con.execute("ALTER TABLE jobs ADD COLUMN discovered_queries_json TEXT NOT NULL DEFAULT '[]'")
rows = con.execute(
"SELECT job_key,title,description FROM jobs WHERE content_language='unknown' AND content_language_source='detected'"
).fetchall()
Expand Down Expand Up @@ -579,27 +585,53 @@ def upsert_job(job: Job) -> bool:
detected = detect_content_language(job.title, job.description)
with connection() as con:
existing = con.execute(
"SELECT job_key,content_language_source FROM jobs WHERE job_key=?", (job.key,)
"""SELECT job_key,description,content_language_source,
source_options_json,discovered_queries_json
FROM jobs WHERE job_key=?""",
(job.key,),
).fetchone()
if existing:
try:
stored_options = json.loads(existing["source_options_json"] or "[]")
except (TypeError, json.JSONDecodeError):
stored_options = []
try:
stored_queries = json.loads(existing["discovered_queries_json"] or "[]")
except (TypeError, json.JSONDecodeError):
stored_queries = []
source_options = list(dict.fromkeys(json.dumps(item, sort_keys=True) for item in stored_options))
for option in job.source_options:
encoded = json.dumps(option, sort_keys=True)
if encoded not in source_options:
source_options.append(encoded)
merged_options = [json.loads(item) for item in source_options]
merged_queries = list(dict.fromkeys([*stored_queries, *job.discovered_queries]))
description = (
existing["description"]
if len(existing["description"] or "") > len(job.description or "")
else job.description
)
language_sql = ""
language_params = ()
if existing["content_language_source"] != "manual":
language_sql = ", content_language=?, content_language_confidence=?, content_language_source='detected'"
language_params = (detected.code, detected.confidence)
con.execute(
f"""UPDATE jobs SET title=?, company=?, location=?, url=?, description=?, created_at=?,
remote=?, score=?, reasons_json=?, last_seen=?{language_sql} WHERE job_key=?""",
remote=?, score=?, reasons_json=?, source_options_json=?, discovered_queries_json=?,
last_seen=?{language_sql} WHERE job_key=?""",
(
job.title,
job.company,
job.location,
job.url,
job.description,
description,
job.created_at,
int(job.remote),
job.score,
json.dumps(job.reasons, ensure_ascii=False),
json.dumps(merged_options, ensure_ascii=False),
json.dumps(merged_queries, ensure_ascii=False),
now,
*language_params,
job.key,
Expand All @@ -609,8 +641,9 @@ def upsert_job(job: Job) -> bool:
con.execute(
"""INSERT INTO jobs(job_key, source, external_id, title, company, location, url,
description, created_at, remote, score, reasons_json, first_seen, last_seen, notified,
content_language, content_language_confidence, content_language_source)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'detected')""",
content_language, content_language_confidence, content_language_source,
source_options_json, discovered_queries_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 'detected', ?, ?)""",
(
job.key,
job.source,
Expand All @@ -628,6 +661,8 @@ def upsert_job(job: Job) -> bool:
now,
detected.code,
detected.confidence,
json.dumps(job.source_options, ensure_ascii=False),
json.dumps(job.discovered_queries, ensure_ascii=False),
),
)
return True
Expand Down
18 changes: 14 additions & 4 deletions app/employment_filter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re

from .models import Job
from .search_intent import ROLE_FAMILIES, ROLE_QUERY_TERMS, role_families_for_terms
from .search_intent import ROLE_FAMILIES, ROLE_QUERY_TERMS, role_families_for_terms, role_queries
from .text_match import contains_affirmed_phrase


Expand Down Expand Up @@ -140,6 +140,7 @@ def search_terms_for_profile(profile: dict, configured_terms: list[str] | None =
title_terms = list((keywords.get("title") or {}).keys())
intent_terms = configured if configured_terms is not None else [*title_terms, *configured]
requested_families = role_families_for_terms(intent_terms)
role_level = str(profile.get("role_level") or "any")
student_targeted = _profile_targets_students(profile)
if (
student_targeted
Expand All @@ -161,6 +162,12 @@ def search_terms_for_profile(profile: dict, configured_terms: list[str] | None =
)
generated: list[str] = []
covered_families: set[str] = set()
if role_level in {"technician", "engineer"}:
for family in requested_families:
specific = role_queries(family, role_level)
if specific:
generated.extend(specific[:2])
covered_families.add(family)

# Keep the first configured phrase for each family and every unknown/custom role.
# Repeated synonyms from one family move behind this coverage pass instead of
Expand All @@ -172,20 +179,23 @@ def search_terms_for_profile(profile: dict, configured_terms: list[str] | None =
covered_families.update(families)
for family in requested_families:
if family not in covered_families:
generated.append(ROLE_QUERY_TERMS.get(family, ROLE_FAMILIES[family][:2])[0])
family_queries = role_queries(family, role_level) or ROLE_FAMILIES[family][:2]
generated.append(family_queries[0])
covered_families.add(family)

# Add the other language, then any remaining user phrases, before narrower
# part-time variants. This preserves user intent without starving later roles.
for family in requested_families:
for query in ROLE_QUERY_TERMS.get(family, ROLE_FAMILIES[family][:2]):
for query in role_queries(family, role_level) or ROLE_FAMILIES[family][:2]:
if query not in generated:
generated.append(query)
generated.extend(base_configured)
generated.extend(configured)
if profile_targets_part_time(profile) and not profile_targets_full_time(profile):
for family in requested_families:
english, german = ROLE_QUERY_TERMS.get(family, ROLE_FAMILIES[family][:2])
family_queries = role_queries(family, role_level) or ROLE_QUERY_TERMS.get(family, ROLE_FAMILIES[family][:2])
english = family_queries[0]
german = family_queries[1] if len(family_queries) > 1 else family_queries[0]
generated.extend((f"{german} teilzeit", f"{english} part time", f"{german} minijob"))
if not generated:
generated.extend(title_terms)
Expand Down
33 changes: 29 additions & 4 deletions app/feedback_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"junior",
"senior",
}
MIN_RULE_EVIDENCE = 2


def _now():
Expand Down Expand Up @@ -161,7 +162,17 @@ def record_feedback(job_key, suitability, reason="", note="", learn=True, profil
if not row:
raise ValueError("Job not found")
rules = _suggest_rules(dict(row), reason) if suitability == "not_suitable" and learn else []
ids = [_upsert_rule(con, profile_id, r, reason) for r in rules]
previous = con.execute(
"""SELECT generated_rules_json FROM job_feedback
WHERE job_key=? AND profile_id=? AND suitability=? AND reason=?
ORDER BY id DESC LIMIT 1""",
(job_key, profile_id, suitability, reason),
).fetchone()
ids = (
json.loads(previous["generated_rules_json"] or "[]")
if previous
else [_upsert_rule(con, profile_id, rule, reason) for rule in rules]
)
now = _now()
con.execute(
"INSERT INTO job_feedback(job_key,profile_id,suitability,reason,note,generated_rules_json,created_at) VALUES(?,?,?,?,?,?,?)",
Expand Down Expand Up @@ -227,7 +238,16 @@ def list_learned_rules(profile_id=None):
f"SELECT * FROM learned_rules {where} ORDER BY enabled DESC,evidence_count DESC,ABS(weight) DESC,term",
params,
).fetchall()
negative = [{**dict(r), "enabled": bool(r["enabled"]), "polarity": "penalty", "strongest_event": ""} for r in rows]
negative = [
{
**dict(r),
"enabled": bool(r["enabled"]),
"ready": int(r["evidence_count"]) >= MIN_RULE_EVIDENCE,
"polarity": "penalty",
"strongest_event": "",
}
for r in rows
]
from .positive_learning import list_positive_rules

positive = []
Expand Down Expand Up @@ -274,7 +294,9 @@ def apply_learned_penalty(job, base_score, profile_id=1):
ensure_feedback_schema()
with connection() as con:
rules = con.execute(
"SELECT scope,term,weight FROM learned_rules WHERE enabled=1 AND profile_id=?", (profile_id,)
"""SELECT scope,term,weight FROM learned_rules
WHERE enabled=1 AND evidence_count>=? AND profile_id=?""",
(MIN_RULE_EVIDENCE, profile_id),
).fetchall()
fields = {
"title": _normalise(getattr(job, "title", "")),
Expand Down Expand Up @@ -306,7 +328,10 @@ def feedback_stats(profile_id=None):
params,
).fetchone()[0]
rules = con.execute(
"SELECT COUNT(*) FROM learned_rules" + (pf + " AND " if pf else " WHERE ") + "enabled=1", params
"SELECT COUNT(*) FROM learned_rules"
+ (pf + " AND " if pf else " WHERE ")
+ "enabled=1 AND evidence_count>=2",
params,
).fetchone()[0]
from .positive_learning import positive_stats

Expand Down
Loading
Loading