diff --git a/CHANGELOG.md b/CHANGELOG.md
index b210f10..b0503c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
@@ -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.
@@ -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.
diff --git a/README.md b/README.md
index faaa6ed..cff8fde 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
@@ -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.
diff --git a/app/db.py b/app/db.py
index 85ea316..26be1ad 100644
--- a/app/db.py
+++ b/app/db.py
@@ -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);
@@ -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()
@@ -579,9 +585,32 @@ 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":
@@ -589,17 +618,20 @@ def upsert_job(job: Job) -> bool:
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,
@@ -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,
@@ -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
diff --git a/app/employment_filter.py b/app/employment_filter.py
index d4596ed..61b9d63 100644
--- a/app/employment_filter.py
+++ b/app/employment_filter.py
@@ -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
@@ -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
@@ -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
@@ -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)
diff --git a/app/feedback_store.py b/app/feedback_store.py
index dd670af..d9d8aa8 100644
--- a/app/feedback_store.py
+++ b/app/feedback_store.py
@@ -51,6 +51,7 @@
"junior",
"senior",
}
+MIN_RULE_EVIDENCE = 2
def _now():
@@ -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(?,?,?,?,?,?,?)",
@@ -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 = []
@@ -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", "")),
@@ -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
diff --git a/app/intelligence-settings-ui.js b/app/intelligence-settings-ui.js
index a7a7e9a..87c0401 100644
--- a/app/intelligence-settings-ui.js
+++ b/app/intelligence-settings-ui.js
@@ -1 +1 @@
-(()=>{const $=id=>document.getElementById(id);async function install(){for(let i=0;i<20&&!$('intelligence');i++)await new Promise(r=>setTimeout(r,100));const s=$('intelligence');if(!s||$('aiSettingsCard'))return;const c=document.createElement('div');c.id='aiSettingsCard';c.className='card';c.style.marginBottom='14px';c.innerHTML=`
Hybrid CV Match Engine
Evidence-based scoring always runs. Optional Ollama adds contextual analysis at 30%; it cannot change requirement evidence status or invent CV experience.
Deterministic
70%
AI context
30%
Fallback
Evidence only
CV and job text are sent only to the configured Ollama endpoint.
`;s.insertBefore(c,s.firstChild);try{const d=await api('/api/intelligence/settings');$('ollamaEnabled').checked=d.ollama_enabled;$('ollamaUrl').value=d.ollama_url;$('ollamaModel').value=d.ollama_model;$('ollamaTimeout').value=d.ollama_timeout_seconds||60}catch(e){}$('saveAiSettings').onclick=async()=>{try{await api('/api/intelligence/settings',{method:'PUT',body:JSON.stringify({ollama_enabled:$('ollamaEnabled').checked,ollama_url:$('ollamaUrl').value.trim(),ollama_model:$('ollamaModel').value.trim(),ollama_timeout_seconds:+$('ollamaTimeout').value||60})});$('aiSettingsStatus').textContent='Saved';$('aiSettingsStatus').className='status ok'}catch(e){$('aiSettingsStatus').textContent=e.message;$('aiSettingsStatus').className='status error'}}}install()})();
\ No newline at end of file
+(()=>{const $=id=>document.getElementById(id);async function install(){for(let i=0;i<20&&!$('intelligence');i++)await new Promise(r=>setTimeout(r,100));const s=$('intelligence');if(!s||$('aiSettingsCard'))return;const c=document.createElement('div');c.id='aiSettingsCard';c.className='card';c.style.marginBottom='14px';c.innerHTML=`
Hybrid CV Match Engine
Evidence-based scoring always runs. Optional Ollama adds contextual analysis at 30%; it cannot change requirement evidence status or invent CV experience.
Deterministic
70%
AI context
30%
Fallback
Evidence only
CV and job text are sent only to the configured Ollama endpoint.
`;s.insertBefore(c,s.firstChild);try{const d=await api('/api/intelligence/settings');$('ollamaEnabled').checked=d.ollama_enabled;$('ollamaUrl').value=d.ollama_url;$('ollamaModel').value=d.ollama_model;$('ollamaTimeout').value=d.ollama_timeout_seconds||60}catch(e){}$('saveAiSettings').onclick=async()=>{try{const current=await api('/api/intelligence/settings');await api('/api/intelligence/settings',{method:'PUT',body:JSON.stringify({ollama_enabled:$('ollamaEnabled').checked,ollama_url:$('ollamaUrl').value.trim(),ollama_model:$('ollamaModel').value.trim(),ollama_timeout_seconds:+$('ollamaTimeout').value||60,semantic_rerank_enabled:current.semantic_rerank_enabled,semantic_model:current.semantic_model,semantic_weight:current.semantic_weight})});$('aiSettingsStatus').textContent='Saved';$('aiSettingsStatus').className='status ok'}catch(e){$('aiSettingsStatus').textContent=e.message;$('aiSettingsStatus').className='status error'}}}install()})();
diff --git a/app/job_enrichment.py b/app/job_enrichment.py
new file mode 100644
index 0000000..eaaff54
--- /dev/null
+++ b/app/job_enrichment.py
@@ -0,0 +1,282 @@
+"""Deterministic vacancy enrichment with evidence and confidence.
+
+The matcher must distinguish "not found" from "does not match". This module
+extracts the small set of facts that can make a vacancy ineligible without
+asking an LLM to invent missing details.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import html
+import ipaddress
+import json
+import re
+import socket
+from urllib.parse import urljoin, urlsplit
+
+import httpx
+from bs4 import BeautifulSoup
+
+
+_HOURS = re.compile(
+ r"(?i)(? str:
+ return re.sub(r"\s+", " ", html.unescape(str(value or ""))).strip()
+
+
+def extract_job_facts(title: str, description: str, location: str = "") -> dict:
+ """Extract eligibility facts and retain the exact supporting snippets."""
+ text = _clean(f"{title}\n{description}")
+ evidence: dict[str, list[str]] = {}
+
+ hours = None
+ hours_match = _HOURS.search(text)
+ if hours_match:
+ hours = int(hours_match.group(2) or hours_match.group(1))
+ evidence["weekly_hours"] = [hours_match.group(0)]
+
+ workload = None
+ workload_match = _WORKLOAD.search(text)
+ if workload_match:
+ workload = int(workload_match.group(1) or workload_match.group(2))
+ evidence["workload_pct"] = [workload_match.group(0)]
+
+ part_time = (
+ bool(_PART_TIME.search(text))
+ or (hours is not None and hours <= 32)
+ or (workload is not None and workload <= 80)
+ )
+ full_time = (
+ bool(_FULL_TIME.search(text))
+ or (hours is not None and hours >= 35)
+ or (workload is not None and workload >= 90)
+ )
+ employment_type = "part_time" if part_time else "full_time" if full_time else "unknown"
+ employment_match = _PART_TIME.search(text) if part_time else _FULL_TIME.search(text) if full_time else None
+ if employment_match:
+ evidence["employment_type"] = [employment_match.group(0)]
+
+ experience_years = None
+ experience_match = _EXPERIENCE.search(text)
+ if experience_match:
+ experience_years = int(experience_match.group(2) or experience_match.group(1))
+ evidence["experience_years"] = [experience_match.group(0)]
+
+ languages: dict[str, str] = {}
+ for match in _LANGUAGE_LEVEL.finditer(text):
+ language = (match.group(1) or match.group(4) or "").lower()
+ level = (match.group(2) or match.group(3) or "").lower()
+ key = "de" if language in {"deutsch", "german"} else "en"
+ languages[key] = level
+ evidence.setdefault(f"language_{key}", []).append(match.group(0))
+
+ student_match = _STUDENT.search(text)
+ student_required = bool(student_match)
+ if student_match:
+ evidence["student_required"] = [student_match.group(0)]
+
+ specialization = ""
+ for label, pattern in _QUALITY_SPECIALIZATIONS:
+ match = pattern.search(_clean(title))
+ if match:
+ specialization = label
+ evidence["role_specialization"] = [match.group(0)]
+ break
+
+ found = sum(
+ value not in (None, "", {}, False, "unknown")
+ for value in (hours, workload, employment_type, experience_years, languages, student_required, specialization)
+ )
+ return {
+ "employment_type": employment_type,
+ "weekly_hours": hours,
+ "workload_pct": workload,
+ "student_required": student_required,
+ "experience_years": experience_years,
+ "language_levels": languages,
+ "role_specialization": specialization,
+ "location": _clean(location),
+ "confidence": "high" if found >= 3 else "medium" if found >= 1 else "unknown",
+ "evidence": evidence,
+ }
+
+
+def _public_http_url(url: str) -> str:
+ parsed = urlsplit(str(url or "").strip())
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
+ raise ValueError("Only public HTTP(S) job URLs are supported")
+ try:
+ addresses = {item[4][0] for item in socket.getaddrinfo(parsed.hostname, parsed.port or 443)}
+ except OSError as exc:
+ raise ValueError("Job URL hostname could not be resolved") from exc
+ for address in addresses:
+ ip = ipaddress.ip_address(address)
+ if not ip.is_global:
+ raise ValueError("Private or local job URLs are not allowed")
+ return parsed.geturl()
+
+
+def _jobposting_from_json(value):
+ if isinstance(value, list):
+ for item in value:
+ found = _jobposting_from_json(item)
+ if found:
+ return found
+ if isinstance(value, dict):
+ kind = value.get("@type")
+ if kind == "JobPosting" or isinstance(kind, list) and "JobPosting" in kind:
+ return value
+ return _jobposting_from_json(value.get("@graph", []))
+ return None
+
+
+async def fetch_public_job(url: str, *, max_bytes: int = 2_000_000) -> dict:
+ """Fetch a public vacancy page, validating every redirect against SSRF."""
+ current = await asyncio.to_thread(_public_http_url, url)
+ headers = {"User-Agent": "BertJobAnalyzer/20 (+https://github.com/emnl51/bert)"}
+ async with httpx.AsyncClient(timeout=20, follow_redirects=False, headers=headers) as client:
+ for _ in range(4):
+ response = await client.get(current)
+ if response.status_code in {301, 302, 303, 307, 308}:
+ location = response.headers.get("location", "")
+ if not location:
+ raise ValueError("Job page redirect has no destination")
+ current = await asyncio.to_thread(_public_http_url, urljoin(current, location))
+ continue
+ response.raise_for_status()
+ content_type = response.headers.get("content-type", "")
+ if "html" not in content_type.lower():
+ raise ValueError("Job URL did not return an HTML page")
+ if len(response.content) > max_bytes:
+ raise ValueError("Job page is too large to analyze")
+ soup = BeautifulSoup(response.text, "html.parser")
+ posting = None
+ for script in soup.select('script[type="application/ld+json"]'):
+ try:
+ posting = _jobposting_from_json(json.loads(script.string or "null"))
+ except (TypeError, json.JSONDecodeError):
+ continue
+ if posting:
+ break
+ posting = posting or {}
+ organization = posting.get("hiringOrganization") or {}
+ location = posting.get("jobLocation") or {}
+ if isinstance(location, list):
+ location = location[0] if location else {}
+ address = location.get("address") if isinstance(location, dict) else {}
+ if not isinstance(address, dict):
+ address = {}
+ description = _clean(BeautifulSoup(str(posting.get("description") or ""), "html.parser").get_text(" "))
+ if not description:
+ main = soup.select_one("main, article, [itemprop=description]") or soup.body
+ description = _clean(main.get_text(" ", strip=True) if main else "")[:50_000]
+ title = _clean(posting.get("title") or (soup.title.string if soup.title else ""))
+ return {
+ "url": current,
+ "title": title,
+ "company": _clean(organization.get("name") if isinstance(organization, dict) else ""),
+ "location": _clean(
+ ", ".join(
+ str(address.get(key) or "")
+ for key in ("addressLocality", "addressRegion", "addressCountry")
+ if address.get(key)
+ )
+ ),
+ "description": description,
+ "published_at": _clean(posting.get("datePosted")),
+ "employment_type": posting.get("employmentType") or "",
+ }
+ raise ValueError("Too many redirects while fetching job page")
+
+
+async def enrich_jobs(jobs: list, *, limit: int = 8, priority_terms: list[str] | None = None) -> dict:
+ """Enrich only evidence-poor candidates with bounded concurrent requests."""
+ candidates = [
+ job
+ for job in jobs
+ if getattr(job, "url", "")
+ and (urlsplit(str(getattr(job, "url", ""))).hostname or "").lower() not in {"example.com", "www.example.com"}
+ and (
+ len(str(getattr(job, "description", "") or "").split()) < 35
+ or extract_job_facts(job.title, job.description, job.location)["employment_type"] == "unknown"
+ )
+ ]
+ if priority_terms:
+ intent_tokens = [set(_clean(term).lower().split()) for term in priority_terms if _clean(term)]
+
+ def priority(job) -> float:
+ title_tokens = set(_clean(getattr(job, "title", "")).lower().split())
+ return max((len(title_tokens & terms) / len(terms) for terms in intent_tokens if terms), default=0.0)
+
+ candidates.sort(key=priority, reverse=True)
+ candidates = candidates[: max(0, min(int(limit), 20))]
+ semaphore = asyncio.Semaphore(4)
+ enriched = failed = 0
+
+ async def enrich(job):
+ nonlocal enriched, failed
+ try:
+ async with semaphore:
+ detail = await fetch_public_job(job.url)
+ except Exception:
+ failed += 1
+ job.enrichment_status = "unavailable"
+ return
+ description = str(detail.get("description") or "")
+ if detail.get("employment_type"):
+ description = f"Employment type: {detail['employment_type']}\n{description}".strip()
+ if len(description) > len(str(job.description or "")):
+ job.description = description
+ for field in ("title", "company", "location", "created_at"):
+ detail_key = "published_at" if field == "created_at" else field
+ if not getattr(job, field, "") and detail.get(detail_key):
+ setattr(job, field, str(detail[detail_key]))
+ job.enrichment_status = "enriched"
+ enriched += 1
+
+ await asyncio.gather(*(enrich(job) for job in candidates))
+ return {"attempted": len(candidates), "enriched": enriched, "failed": failed}
diff --git a/app/job_metadata.py b/app/job_metadata.py
index f81b3a8..1c7acf1 100644
--- a/app/job_metadata.py
+++ b/app/job_metadata.py
@@ -1,6 +1,7 @@
import re
from datetime import date, datetime, timedelta, timezone
+from .job_enrichment import extract_job_facts
from .search_intent import ROLE_FAMILIES, matched_role_families
@@ -129,6 +130,7 @@ def classify_job_metadata(job: dict, today: date | None = None) -> dict:
quality += 10 if published else 0
quality += 10 if employment_type != "unknown" else 0
quality += 5 if categories else 0
+ facts = extract_job_facts(title, description, location)
return {
"categories": categories or ["Other"],
@@ -145,4 +147,10 @@ def classify_job_metadata(job: dict, today: date | None = None) -> dict:
"freshness_label": freshness_label,
"data_quality": min(100, quality),
"description_preview": description[:260].rstrip(),
+ "student_required": facts["student_required"],
+ "experience_years": facts["experience_years"],
+ "language_levels": facts["language_levels"],
+ "role_specialization": facts["role_specialization"],
+ "fact_confidence": facts["confidence"],
+ "fact_evidence": facts["evidence"],
}
diff --git a/app/jobspy_provider.py b/app/jobspy_provider.py
index 5c21aeb..620612f 100644
--- a/app/jobspy_provider.py
+++ b/app/jobspy_provider.py
@@ -133,6 +133,7 @@ async def scrape_site(site: str):
description=description,
created_at=created,
remote=bool(row.get("is_remote", False)),
+ discovered_queries=[term],
)
)
return site_jobs, site_failures
diff --git a/app/matching-diagnostics-ui.js b/app/matching-diagnostics-ui.js
new file mode 100644
index 0000000..64a8642
--- /dev/null
+++ b/app/matching-diagnostics-ui.js
@@ -0,0 +1,120 @@
+(() => {
+ const $ = id => document.getElementById(id);
+ const esc = value => String(value ?? '').replace(/[&<>"']/g, char => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ })[char]);
+
+ function install() {
+ const list = $('sjListView');
+ if (!list || $('matchingDiagnostics')) return false;
+ const card = document.createElement('details');
+ card.id = 'matchingDiagnostics';
+ card.className = 'card';
+ card.style.marginBottom = '14px';
+ card.innerHTML = `
+ Why was a job missed?
+
Paste a public vacancy URL. Bert fetches available details, replays every matching gate and can retain the example as a regression benchmark.
`;
+ $('mdResult').innerHTML = rows('Source quality', result.sources || []) + rows('Query quality', result.queries || []);
+ $('mdStatus').textContent = 'Quality report loaded';
+ $('mdStatus').className = 'status ok';
+ } catch (error) {
+ $('mdStatus').textContent = error.message;
+ $('mdStatus').className = 'status error';
+ }
+ }
+
+ if (!install()) {
+ let attempts = 0;
+ const timer = setInterval(() => { if (install() || ++attempts > 30) clearInterval(timer); }, 100);
+ }
+})();
diff --git a/app/matching_diagnostics.py b/app/matching_diagnostics.py
new file mode 100644
index 0000000..8e4692a
--- /dev/null
+++ b/app/matching_diagnostics.py
@@ -0,0 +1,362 @@
+"""Explain matching decisions and retain user-labelled regression examples."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+from urllib.parse import urlsplit, urlunsplit
+
+from .db import connection
+from .employment_filter import assess_employment_fit, is_hard_employment_exclusion
+from .feedback_store import apply_learned_penalty
+from .job_enrichment import extract_job_facts
+from .models import Job
+from .positive_learning import apply_positive_boost
+from .ranker import (
+ assess_language_fit,
+ assess_role_relevance,
+ blocklist_matches,
+ calculate_overall_score,
+ profile_english_level,
+ score_job,
+)
+from .search_job_service import keyword_rules_for_job, search_terms_for_job
+
+
+DIAGNOSTIC_SCHEMA = """
+CREATE TABLE IF NOT EXISTS matching_benchmarks (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER,
+ search_job_id INTEGER NOT NULL,
+ profile_id INTEGER NOT NULL,
+ url TEXT NOT NULL,
+ title TEXT NOT NULL,
+ company TEXT NOT NULL DEFAULT '',
+ location TEXT NOT NULL DEFAULT '',
+ description TEXT NOT NULL DEFAULT '',
+ expected_relevant INTEGER NOT NULL DEFAULT 1,
+ note TEXT NOT NULL DEFAULT '',
+ last_prediction INTEGER,
+ last_stage TEXT NOT NULL DEFAULT '',
+ last_diagnosed_at TEXT,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE(user_id, search_job_id, url),
+ FOREIGN KEY(search_job_id) REFERENCES search_jobs(id) ON DELETE CASCADE,
+ FOREIGN KEY(profile_id) REFERENCES search_profiles(id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_matching_benchmarks_job
+ ON matching_benchmarks(user_id, search_job_id, updated_at DESC);
+"""
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _owner_id(user_id) -> int:
+ return int(user_id) if user_id is not None else 0
+
+
+def ensure_matching_diagnostic_schema() -> None:
+ with connection() as con:
+ con.executescript(DIAGNOSTIC_SCHEMA)
+ con.execute("UPDATE matching_benchmarks SET user_id=0 WHERE user_id IS NULL")
+
+
+def canonical_url(value: str) -> str:
+ try:
+ parts = urlsplit(str(value or "").strip())
+ except ValueError:
+ return ""
+ host = (parts.hostname or "").removeprefix("www.").lower()
+ return urlunsplit((parts.scheme.lower(), host, parts.path.rstrip("/"), "", "")) if host else ""
+
+
+def _discovery_status(url: str, search_job_id: int) -> dict:
+ wanted = canonical_url(url)
+ if not wanted:
+ return {"stored": False, "seen_by_search_job": False}
+ with connection() as con:
+ rows = con.execute(
+ "SELECT job_key,url,source,source_options_json FROM jobs WHERE url IS NOT NULL AND url!=''"
+ ).fetchall()
+ matched = None
+ for row in rows:
+ urls = [row["url"]]
+ try:
+ urls.extend(option.get("url", "") for option in json.loads(row["source_options_json"] or "[]"))
+ except (TypeError, json.JSONDecodeError):
+ pass
+ if any(canonical_url(url) == wanted for url in urls):
+ matched = row
+ break
+ if not matched:
+ return {"stored": False, "seen_by_search_job": False}
+ seen = con.execute(
+ "SELECT 1 FROM search_job_seen WHERE search_job_id=? AND job_key=?",
+ (search_job_id, matched["job_key"]),
+ ).fetchone()
+ return {
+ "stored": True,
+ "seen_by_search_job": bool(seen),
+ "job_key": matched["job_key"],
+ "source": matched["source"],
+ }
+
+
+def diagnose_job(payload: dict, search_job: dict, profile: dict) -> dict:
+ job = Job(
+ source="Diagnostic",
+ external_id=canonical_url(payload.get("url", "")) or "manual",
+ title=str(payload.get("title") or "").strip(),
+ company=str(payload.get("company") or "").strip(),
+ location=str(payload.get("location") or "").strip(),
+ url=str(payload.get("url") or "").strip(),
+ description=str(payload.get("description") or "").strip(),
+ created_at=str(payload.get("published_at") or "").strip(),
+ remote=bool(payload.get("remote", False)),
+ )
+ search_terms = search_terms_for_job(search_job, profile)
+ keywords = keyword_rules_for_job(search_job, profile)
+ location_terms = (
+ profile.get("location_terms") or []
+ if search_job.get("inherit_location")
+ else search_job.get("location_terms") or profile.get("location_terms") or []
+ )
+ min_score = (
+ profile["min_score"] if search_job.get("min_score_override") is None else int(search_job["min_score_override"])
+ )
+ min_language = (
+ profile["min_language_score"]
+ if search_job.get("min_language_score_override") is None
+ else int(search_job["min_language_score_override"])
+ )
+ strict = search_job.get("employment_mode", "prefer") == "strict"
+ custom_intent = bool(search_job.get("search_terms"))
+ stages: list[dict] = []
+
+ blocked = blocklist_matches(job, keywords)
+ stages.append(
+ {
+ "stage": "blocklist",
+ "passed": not blocked,
+ "detail": "No hard exclusion matched" if not blocked else f"Matched: {', '.join(blocked)}",
+ }
+ )
+ job.score, job.reasons = score_job(job, keywords, location_terms, search_terms, restrict_to_intent=custom_intent)
+ role = assess_role_relevance(
+ job,
+ keywords,
+ search_terms,
+ restrict_to_intent=custom_intent,
+ role_level=profile.get("role_level", "any"),
+ )
+ stages.append(
+ {
+ "stage": "role",
+ "passed": role.relevant,
+ "detail": "; ".join(role.reasons),
+ "confidence": role.confidence,
+ "requested_families": list(role.requested_families),
+ "matched_families": list(role.matched_families),
+ }
+ )
+ employment_ok, employment_label, employment_reasons = assess_employment_fit(job, profile, strict=strict)
+ hard_employment = is_hard_employment_exclusion(profile, employment_ok, employment_label, strict=strict)
+ stages.append(
+ {
+ "stage": "working_time",
+ "passed": not hard_employment and (employment_ok or not strict),
+ "hard_exclusion": hard_employment,
+ "detail": "; ".join(employment_reasons) or employment_label,
+ }
+ )
+ language_profile = {
+ "primary_working_language": "English",
+ "current_english_level": profile_english_level(profile),
+ "current_german_level": profile["current_german_level"],
+ "max_german_requirement": profile["max_german_requirement"],
+ "prefer_german_growth": profile["prefer_german_growth"],
+ }
+ job.language_score, job.language_label, job.language_reasons = assess_language_fit(job, language_profile)
+ stages.append(
+ {
+ "stage": "language",
+ "passed": job.language_score >= min_language,
+ "score": job.language_score,
+ "threshold": min_language,
+ "detail": "; ".join(job.language_reasons) or job.language_label,
+ }
+ )
+ score_before_learning = job.score
+ job.score, negative = apply_learned_penalty(job, job.score, profile_id=profile["id"])
+ job.score, positive = apply_positive_boost(job, job.score, profile_id=profile["id"])
+ stages.append(
+ {
+ "stage": "learning",
+ "passed": True,
+ "score_delta": job.score - score_before_learning,
+ "detail": "; ".join([*negative, *positive]) or "No learned rule changed this score",
+ }
+ )
+ job.overall_score = calculate_overall_score(job.score, job.language_score, profile["language_weight"])
+ stages.append(
+ {
+ "stage": "fit",
+ "passed": job.overall_score >= min_score,
+ "score": job.overall_score,
+ "job_score": job.score,
+ "threshold": min_score,
+ "detail": "; ".join(job.reasons[:8]) or "No positive fit evidence",
+ }
+ )
+ language_allowed = not (profile.get("hide_german_heavy") and job.language_label == "german_heavy") and not (
+ not profile.get("show_b2_stretch") and job.language_label == "stretch"
+ )
+ eligible = bool(
+ not blocked
+ and role.relevant
+ and not hard_employment
+ and employment_ok
+ and job.overall_score >= min_score
+ and job.language_score >= min_language
+ and language_allowed
+ )
+ first_failure = next((stage["stage"] for stage in stages if not stage["passed"]), "recommended")
+ discovery = _discovery_status(job.url, int(search_job["id"]))
+ if not discovery["stored"]:
+ summary = "The vacancy has not been stored; retrieval/source coverage is the likely first failure."
+ elif eligible:
+ summary = "The vacancy passes the current profile and search-job rules."
+ else:
+ summary = f"The vacancy was found but fails first at the {first_failure} stage."
+ return {
+ "eligible": eligible,
+ "first_failure": first_failure,
+ "summary": summary,
+ "discovery": discovery,
+ "query_plan": search_terms,
+ "facts": extract_job_facts(job.title, job.description, job.location),
+ "scores": {
+ "job": job.score,
+ "language": job.language_score,
+ "overall": job.overall_score,
+ "minimum_overall": min_score,
+ "minimum_language": min_language,
+ },
+ "stages": stages,
+ }
+
+
+def save_benchmark(payload: dict, search_job: dict, profile: dict, diagnosis: dict, user_id=None) -> int:
+ ensure_matching_diagnostic_schema()
+ now = _now()
+ values = (
+ _owner_id(user_id),
+ int(search_job["id"]),
+ int(profile["id"]),
+ canonical_url(payload.get("url", "")) or str(payload.get("url", "")).strip(),
+ str(payload.get("title") or "").strip(),
+ str(payload.get("company") or "").strip(),
+ str(payload.get("location") or "").strip(),
+ str(payload.get("description") or "").strip(),
+ int(bool(payload.get("expected_relevant", True))),
+ str(payload.get("note") or "").strip(),
+ int(bool(diagnosis["eligible"])),
+ diagnosis["first_failure"],
+ now,
+ now,
+ now,
+ )
+ with connection() as con:
+ con.execute(
+ """INSERT INTO matching_benchmarks(
+ user_id,search_job_id,profile_id,url,title,company,location,description,
+ expected_relevant,note,last_prediction,last_stage,last_diagnosed_at,created_at,updated_at
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(user_id,search_job_id,url) DO UPDATE SET
+ profile_id=excluded.profile_id,title=excluded.title,company=excluded.company,
+ location=excluded.location,description=excluded.description,
+ expected_relevant=excluded.expected_relevant,note=excluded.note,
+ last_prediction=excluded.last_prediction,last_stage=excluded.last_stage,
+ last_diagnosed_at=excluded.last_diagnosed_at,updated_at=excluded.updated_at""",
+ values,
+ )
+ row = con.execute(
+ "SELECT id FROM matching_benchmarks WHERE user_id=? AND search_job_id=? AND url=?",
+ (_owner_id(user_id), int(search_job["id"]), values[3]),
+ ).fetchone()
+ return int(row["id"])
+
+
+def list_benchmarks(search_job_id: int, user_id=None) -> list[dict]:
+ ensure_matching_diagnostic_schema()
+ with connection() as con:
+ rows = con.execute(
+ """SELECT id,url,title,company,location,expected_relevant,note,last_prediction,
+ last_stage,last_diagnosed_at,updated_at
+ FROM matching_benchmarks WHERE user_id=? AND search_job_id=? ORDER BY updated_at DESC""",
+ (_owner_id(user_id), search_job_id),
+ ).fetchall()
+ return [
+ {
+ **dict(row),
+ "expected_relevant": bool(row["expected_relevant"]),
+ "last_prediction": None if row["last_prediction"] is None else bool(row["last_prediction"]),
+ }
+ for row in rows
+ ]
+
+
+def run_benchmarks(search_job: dict, profile: dict, user_id=None) -> dict:
+ ensure_matching_diagnostic_schema()
+ with connection() as con:
+ rows = con.execute(
+ "SELECT * FROM matching_benchmarks WHERE user_id=? AND search_job_id=? ORDER BY id",
+ (_owner_id(user_id), int(search_job["id"])),
+ ).fetchall()
+ true_positive = false_positive = false_negative = true_negative = 0
+ failures: list[dict] = []
+ now = _now()
+ with connection() as con:
+ for row in rows:
+ payload = dict(row)
+ diagnosis = diagnose_job(payload, search_job, profile)
+ expected = bool(row["expected_relevant"])
+ predicted = bool(diagnosis["eligible"])
+ if expected and predicted:
+ true_positive += 1
+ elif expected:
+ false_negative += 1
+ elif predicted:
+ false_positive += 1
+ else:
+ true_negative += 1
+ if expected != predicted:
+ failures.append(
+ {
+ "id": row["id"],
+ "title": row["title"],
+ "expected_relevant": expected,
+ "predicted_relevant": predicted,
+ "first_failure": diagnosis["first_failure"],
+ }
+ )
+ con.execute(
+ """UPDATE matching_benchmarks SET last_prediction=?,last_stage=?,last_diagnosed_at=?,updated_at=?
+ WHERE id=? AND user_id=?""",
+ (int(predicted), diagnosis["first_failure"], now, now, row["id"], _owner_id(user_id)),
+ )
+ precision_denominator = true_positive + false_positive
+ recall_denominator = true_positive + false_negative
+ return {
+ "total": len(rows),
+ "true_positive": true_positive,
+ "false_positive": false_positive,
+ "false_negative": false_negative,
+ "true_negative": true_negative,
+ "precision": round(true_positive / precision_denominator, 3) if precision_denominator else None,
+ "recall": round(true_positive / recall_denominator, 3) if recall_denominator else None,
+ "failures": failures,
+ }
diff --git a/app/models.py b/app/models.py
index 32e48a6..b2a1acf 100644
--- a/app/models.py
+++ b/app/models.py
@@ -21,6 +21,9 @@ class Job:
match_tier: str = "match"
language_label: str = "unclear"
language_reasons: list[str] = field(default_factory=list)
+ discovered_queries: list[str] = field(default_factory=list)
+ source_options: list[dict[str, str]] = field(default_factory=list)
+ semantic_score: int | None = None
@property
def key(self) -> str:
diff --git a/app/positive_learning.py b/app/positive_learning.py
index 94850ee..e978a9a 100644
--- a/app/positive_learning.py
+++ b/app/positive_learning.py
@@ -17,6 +17,7 @@
"""
EVENT_STRENGTH = {"suitable": 4, "applied": 5, "interview": 9, "offer": 14}
EVENT_RANK = {"suitable": 1, "applied": 2, "interview": 3, "offer": 4}
+MIN_POSITIVE_EVIDENCE = 2
STOPWORDS = {
"werkstudent",
"working",
@@ -278,7 +279,15 @@ def list_positive_rules(profile_id=None):
rows = con.execute(
f"SELECT * FROM positive_rules {where} ORDER BY enabled DESC,evidence_count DESC,weight DESC,term", params
).fetchall()
- return [{**dict(r), "enabled": bool(r["enabled"])} for r in rows]
+ return [
+ {
+ **dict(r),
+ "enabled": bool(r["enabled"]),
+ "ready": int(r["evidence_count"]) >= MIN_POSITIVE_EVIDENCE
+ or r["strongest_event"] in {"interview", "offer"},
+ }
+ for r in rows
+ ]
def set_positive_rule_enabled(rule_id, enabled, user_id=None):
@@ -303,7 +312,10 @@ def apply_positive_boost(job, base_score, profile_id=1):
ensure_positive_schema()
with connection() as con:
rules = con.execute(
- "SELECT scope,term,weight FROM positive_rules WHERE enabled=1 AND profile_id=?", (profile_id,)
+ """SELECT scope,term,weight FROM positive_rules
+ WHERE enabled=1 AND profile_id=?
+ AND (evidence_count>=? OR strongest_event IN ('interview','offer'))""",
+ (profile_id, MIN_POSITIVE_EVIDENCE),
).fetchall()
fields = {
"title": _normalise(getattr(job, "title", "")),
@@ -332,7 +344,10 @@ def positive_stats(profile_id=None):
with connection() as con:
events = con.execute("SELECT COUNT(*) FROM positive_events" + pf, params).fetchone()[0]
rules = con.execute(
- "SELECT COUNT(*) FROM positive_rules" + (pf + " AND " if pf else " WHERE ") + "enabled=1", params
+ "SELECT COUNT(*) FROM positive_rules"
+ + (pf + " AND " if pf else " WHERE ")
+ + "enabled=1 AND (evidence_count>=2 OR strongest_event IN ('interview','offer'))",
+ params,
).fetchone()[0]
interviews = con.execute(
"SELECT COUNT(*) FROM positive_events" + (pf + " AND " if pf else " WHERE ") + "event_type='interview'",
diff --git a/app/profile_store.py b/app/profile_store.py
index 567f7a7..c14fcdf 100644
--- a/app/profile_store.py
+++ b/app/profile_store.py
@@ -608,6 +608,7 @@ def list_jobs_for_profile(
f"""SELECT j.job_key,j.source,j.title,j.company,j.location,j.url,j.description,j.created_at,j.first_seen,j.remote,
COALESCE(js.decision,'unreviewed') AS decision,js.decision_at,
j.content_language,j.content_language_confidence,j.content_language_source,
+ j.source_options_json,j.discovered_queries_json,
s.job_score AS score,s.language_score,s.overall_score,s.role_relevant,s.match_tier,s.language_label,s.reasons_json,s.language_reasons_json,
a.status AS application_status,a.applied_at
FROM job_profile_scores s JOIN jobs j ON j.job_key=s.job_key
@@ -622,6 +623,8 @@ def list_jobs_for_profile(
item = dict(row)
item["reasons"] = json.loads(item.pop("reasons_json") or "[]")
item["language_reasons"] = json.loads(item.pop("language_reasons_json") or "[]")
+ item["source_options"] = json.loads(item.pop("source_options_json") or "[]")
+ item["discovered_queries"] = json.loads(item.pop("discovered_queries_json") or "[]")
item.update(classify_job_metadata(item))
if profile_requires_confirmed_work_time(profile) and item["employment_type"] == "unknown":
continue
@@ -643,7 +646,8 @@ def get_job_for_profile(job_key: str, profile_id: int, user_id: int | None = Non
row = con.execute(
"""SELECT j.job_key,j.source,j.title,j.company,j.location,j.url,j.description,j.created_at,
j.first_seen,j.remote,j.content_language,j.content_language_confidence,
- j.content_language_source,COALESCE(js.decision,'unreviewed') AS decision,
+ j.content_language_source,j.source_options_json,j.discovered_queries_json,
+ COALESCE(js.decision,'unreviewed') AS decision,
js.decision_at,s.job_score AS score,s.language_score,s.overall_score,
s.role_relevant,s.match_tier,s.language_label,s.reasons_json,s.language_reasons_json,
a.status AS application_status,a.applied_at
@@ -659,6 +663,8 @@ def get_job_for_profile(job_key: str, profile_id: int, user_id: int | None = Non
item = dict(row)
item["reasons"] = json.loads(item.pop("reasons_json") or "[]")
item["language_reasons"] = json.loads(item.pop("language_reasons_json") or "[]")
+ item["source_options"] = json.loads(item.pop("source_options_json") or "[]")
+ item["discovered_queries"] = json.loads(item.pop("discovered_queries_json") or "[]")
item.update(classify_job_metadata(item))
if profile_requires_confirmed_work_time(profile) and item["employment_type"] == "unknown":
return None
diff --git a/app/providers.py b/app/providers.py
index 00f3817..a4eaa36 100644
--- a/app/providers.py
+++ b/app/providers.py
@@ -122,6 +122,7 @@ async def fetch_adzuna(source: dict, search_terms: list[str], target_location: s
),
created_at=item.get("created") or "",
remote=False,
+ discovered_queries=[term],
)
)
return jobs
diff --git a/app/review-ui.js b/app/review-ui.js
index 6f8b9ec..ee72626 100644
--- a/app/review-ui.js
+++ b/app/review-ui.js
@@ -52,7 +52,7 @@
`).join(''):'
No jobs match this profile and filters.
'};
let detailTrigger=null;
- window.openJobDetail=async function(jobKey,trigger){detailTrigger=trigger||document.activeElement;const backdrop=$('jobDetailBackdrop'),body=$('jobDetailBody');backdrop.hidden=false;document.body.classList.add('jt-dialog-open');$('jobDetailTitle').textContent='Loading job…';$('jobDetailSubtitle').textContent='';body.innerHTML='
Loading details…
';$('jobDetailClose').focus();try{const d=await api(`/api/jobs/${encodeURIComponent(jobKey)}/detail?profile_id=${encodeURIComponent(window.activeProfileId)}`),j=d.job,description=j.description||j.description_preview;$('jobDetailTitle').textContent=j.title||'Job details';$('jobDetailSubtitle').textContent=`${j.company||'Company not stated'} · ${j.location||'Location not stated'}`;body.innerHTML=`
';$('jobDetailClose').focus();try{const d=await api(`/api/jobs/${encodeURIComponent(jobKey)}/detail?profile_id=${encodeURIComponent(window.activeProfileId)}`),j=d.job,description=j.description||j.description_preview;$('jobDetailTitle').textContent=j.title||'Job details';$('jobDetailSubtitle').textContent=`${j.company||'Company not stated'} · ${j.location||'Location not stated'}`;body.innerHTML=`