Files
skillfactor-pipeline/pipeline/batch_run.py
skillfactor-pipeline 69a7ba6ea7 feat(qa): package linter as publish gate + v1 full-catalog heatmap
lint_package.py checks contamination, alphabetical truncation, count
consistency, provenance completeness and intro grammar; wired into both
publish paths (p4_publish + batch stage_packages), SKIP_LINT=1 reserved
for labeled v1-grade republishes. Heatmap over 3,039 v1 packages:
1,406 clean / 1,633 with findings; C2 truncation is systemic (generator),
contamination affects 2.6% of crawled packages; nothing requires
re-fetching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDKeXvpT6tENSvyQGLV1Uq
2026-07-09 06:54:09 +02:00

522 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Full-catalog batch runner.
Stages (per skillfactor_finalize.md / auftrag_sonnet.md):
1. packages: deterministic generator for ALL ESCO occupations (no LLM)
2. evidence: per occupation fetch (JSearch, budget-capped) -> extract
(Ollama, extract_local.py) -> aggregate (market.md)
3. depth: tier 1 = top-200 by evidence volume, tier 3 = night batch
Every step is idempotent and resumable: progress.json + file existence
decide what still needs work; an abort at any point loses at most the
current occupation's in-flight step.
Usage:
python pipeline/batch_run.py --stage packages [--limit N]
python pipeline/batch_run.py --stage evidence [--slugs a,b] [--limit N]
python pipeline/batch_run.py --stage tier # (re)compute tiers only
"""
# Guard: only the project venv is authorised to run this.
# If spawned by the venv Python itself as a subprocess, the path will be the same.
import sys as _sys, os as _os
_VENV_PYTHON = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)),
"..", ".venv", "Scripts", "python.exe")
if _os.path.abspath(_sys.executable).lower() != _os.path.abspath(_VENV_PYTHON).lower():
print(f"GUARD EXIT: not venv python ({_sys.executable}). Exiting.", flush=True)
_sys.exit(3)
del _sys, _os, _VENV_PYTHON
import argparse
import json
import os
import subprocess
import sys
import time
import requests
from dotenv import load_dotenv
sys.path.insert(0, os.path.dirname(__file__))
import progress
from db import connect
import p2_generate
import p3b_store_evidence as p3b
import p3c_aggregate as p3c
import p4_publish as p4
BASE = os.path.join(os.path.dirname(__file__), "..")
load_dotenv(os.path.join(BASE, ".env"))
ADS_PER_OCC_TARGET = 60
COUNTRIES = ("us", "gb")
REQ_RATE_SLEEP = 0.5 # ~2 req/s max
TOP_TIER_SIZE = 200
THIN_MARKET_THRESHOLD = 20
JSEARCH_URL = "https://api.openwebninja.com/jsearch/search-v2"
ADZUNA_URL = "https://api.adzuna.com/v1/api/jobs/{country}/search/{page}"
def esco_occupations(cur):
"""All ESCO occupations with slug + altLabels for query building.
Returns [(slug, preferred_label, [alt_labels...]), ...] ordered
alphabetically by preferred_label. Slug generation matches p2_generate.py.
"""
cur.execute(
"SELECT concept_uri, preferred_label, alt_labels "
"FROM esco_occupation ORDER BY preferred_label"
)
result = []
for _uri, label, alt_labels_raw in cur.fetchall():
slug = p2_generate.slugify(label)
alts = [a.strip() for a in (alt_labels_raw or "").splitlines() if a.strip()]
result.append((slug, label, alts))
return result
# ---------------------------------------------------------------------------
# Fetch helpers
# ---------------------------------------------------------------------------
def _jsearch_page(params, api_key, slug, state):
"""Spend one budget token, fetch one JSearch page. Returns payload or None."""
progress.spend_request(state, slug)
for attempt in range(3):
try:
r = requests.get(JSEARCH_URL, params=params,
headers={"x-api-key": api_key}, timeout=60)
except requests.RequestException as exc:
print(f" jsearch retry {attempt+1}: {exc}")
time.sleep(8 * (attempt + 1))
continue
if r.status_code == 429:
return None
if r.status_code >= 500:
time.sleep(8 * (attempt + 1))
continue
r.raise_for_status()
return r.json()
return None
def _adzuna_ads(slug, label, app_id, app_key):
"""Adzuna fallback (free, no budget cost). Returns list of ad dicts."""
ads, seen = [], set()
raw_dir = os.path.join(BASE, "data", "raw", "jobs", slug)
os.makedirs(raw_dir, exist_ok=True)
for country in ("gb", "us"):
for page in range(1, 4):
cache = os.path.join(raw_dir, f"adzuna_{country}_p{page}.json")
if os.path.exists(cache):
payload = json.load(open(cache, encoding="utf-8"))
else:
try:
r = requests.get(
ADZUNA_URL.format(country=country, page=page),
params={"app_id": app_id, "app_key": app_key,
"what": label, "results_per_page": 50,
"content-type": "application/json"},
timeout=30)
if r.status_code != 200:
break
payload = r.json()
json.dump(payload, open(cache, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
time.sleep(0.5)
except Exception:
break
for job in payload.get("results", []):
jid = f"adzuna_{job.get('id', '')}"
desc = job.get("description", "")
if jid not in seen and desc:
seen.add(jid)
ads.append({
"job_id": jid,
"title": job.get("title"),
"employer": (job.get("company") or {}).get("display_name"),
"country": country,
"description": desc,
})
if not payload.get("results"):
break
return ads
def _fetch_ads(state, slug, label, alts, api_key, adzuna_id, adzuna_key):
"""Fetch job ads for one occupation (JSearch + optional Adzuna fallback).
Returns deduped list of {job_id, title, employer, country, description}.
Every JSearch HTTP call goes through _jsearch_page which calls
progress.spend_request() BEFORE the request.
"""
raw_dir = os.path.join(BASE, "data", "raw", "jobs", slug)
os.makedirs(raw_dir, exist_ok=True)
ads, seen = [], set()
terms = [label] + alts[:3] # preferred + top-3 altLabels (โ‰ค10 req/occ per arch)
if api_key:
for query in terms:
if len(ads) >= ADS_PER_OCC_TARGET:
break
safe_q = query.replace(" ", "_").replace("/", "-")[:60]
for country in COUNTRIES:
if len(ads) >= ADS_PER_OCC_TARGET:
break
cursor = None
for page in range(1, 6):
if len(ads) >= ADS_PER_OCC_TARGET:
break
cache = os.path.join(
raw_dir, f"jsearch_{safe_q}_{country}_p{page}.json")
if os.path.exists(cache):
payload = json.load(open(cache, encoding="utf-8"))
else:
params = {"query": query, "country": country, "language": "en"}
if cursor:
params["cursor"] = cursor
payload = _jsearch_page(params, api_key, slug, state)
if payload is None:
break
json.dump(payload, open(cache, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
time.sleep(REQ_RATE_SLEEP)
body = payload.get("data") or {}
jobs = (body.get("jobs") if isinstance(body, dict) else body) or []
if not jobs:
break
for job in jobs:
jid = job.get("job_id")
if jid and jid not in seen and job.get("job_description"):
seen.add(jid)
ads.append({
"job_id": jid,
"title": job.get("job_title"),
"employer": job.get("employer_name"),
"country": country,
"description": job.get("job_description"),
})
cursor = (payload.get("cursor")
or (body.get("cursor") if isinstance(body, dict) else None)
or (payload.get("meta") or {}).get("cursor"))
if not cursor:
break
if len(ads) < THIN_MARKET_THRESHOLD and adzuna_id and adzuna_key:
for a in _adzuna_ads(slug, label, adzuna_id, adzuna_key):
if a["job_id"] not in seen:
seen.add(a["job_id"])
ads.append(a)
return ads
# ---------------------------------------------------------------------------
# Stage implementations
# ---------------------------------------------------------------------------
def stage_packages(state, limit=0):
"""Generate package files for ALL 3039 ESCO occupations and push to Gitea.
Reuses p2_generate.all_occupations (LEFT JOIN to O*NET crosswalk; None if
no match) and p2_generate.gen_package (handles None onet_id gracefully).
Gitea pushes via p4_publish: check-before-create, throttled โ‰ฅ1 s/repo.
Resumable: occupations already 'done' in progress.json are skipped.
"""
cn = connect()
try:
p4.ensure_org("skills-core", "public")
p4.ensure_org("skills-community", "public")
p4.ensure_org("tenant-acme", "private")
except Exception as exc:
print(f"WARN: org setup failed ({exc}) โ€” Gitea may be starting up; "
"packages will still be generated locally and push retried per-package")
all_occs = p2_generate.all_occupations(cn)
print(f"ESCO catalog: {len(all_occs)} occupations")
done = skipped = failed = attempted = 0
for r in all_occs:
slug = p2_generate.slugify(r["preferred_label"])
occ_state = progress.occ(state, slug)
if occ_state["package"] == "done":
skipped += 1
continue
attempted += 1
try:
# If files already on disk (failed = push-only failure), skip re-generation
manifest_path = os.path.join(p2_generate.SKILLS_DIR, slug, "manifest.json")
if occ_state["package"] == "failed" and os.path.exists(manifest_path):
actual_slug = slug
manifest = json.load(open(manifest_path, encoding="utf-8"))
else:
actual_slug = p2_generate.gen_package(cn, r)
manifest = json.load(open(
os.path.join(p2_generate.SKILLS_DIR, actual_slug, "manifest.json"),
encoding="utf-8"))
# Phase 3 publish gate (SKIP_LINT=1 = explicitly-labeled v1-grade
# republish, e.g. the evidence-crawl catalog refresh)
if os.environ.get("SKIP_LINT") != "1":
sys.path.insert(0, os.path.join(BASE, "qa"))
import lint_package
lint_findings = lint_package.lint(actual_slug)
if lint_findings:
occ_state["package"] = "failed"
occ_state["error"] = f"lint: {lint_findings[0][:150]}"
progress.save(state)
failed += 1
print(f"LINT-BLOCKED {actual_slug}: {lint_findings[0][:100]}")
continue
p4.ensure_repo("skills-core", actual_slug, manifest.get("title", actual_slug))
p4.push_dir(
"skills-core", actual_slug,
os.path.join(p2_generate.SKILLS_DIR, actual_slug),
f"feat: {actual_slug} skill package v{manifest['version']}")
occ_state["package"] = "done"
progress.save(state)
done += 1
print(f"[{done}] {actual_slug}")
time.sleep(1.0)
except Exception as exc:
occ_state["package"] = "failed"
occ_state["error"] = str(exc)[:200]
progress.save(state)
failed += 1
print(f"FAIL {slug}: {exc}")
if limit and attempted >= limit:
break
cn.close()
print(f"stage_packages: {done} done, {skipped} skipped, {failed} failed")
def stage_evidence(state, slugs=None, limit=0):
"""Per occupation: fetch (cached) -> extract (Ollama) -> store -> aggregate.
fetch: preferredLabel + top-3 altLabels ร— COUNTRIES, cached under
data/raw/jobs/<slug>/; progress.spend_request BEFORE each HTTP call.
< THIN_MARKET_THRESHOLD usable ads -> Adzuna fallback -> mark failed.
extract: subprocess extract_local.py (Ollama gemma3:27b), result in
data/evidence/<slug>.jsonl. Skipped if file already exists.
store: p3b.store_for_occupation โ€” DELETE+INSERT scoped by occupation_slug.
aggregate: p3c.aggregate_for_occupation โ€” market.md + tools.md/skills.md sections.
QA: qa_sample.py --rate 0.02; two consecutive exit-1 = stop (systemic).
"""
cn = connect()
cur = cn.cursor()
occ_map = {slug: (label, alts) for slug, label, alts in esco_occupations(cur)}
cn.close()
api_key = os.environ.get("JSEARCH_API_KEY", "").strip()
adzuna_id = os.environ.get("ADZUNA_APP_ID", "").strip()
adzuna_key = os.environ.get("ADZUNA_APP_KEY", "").strip()
if not api_key:
print("WARN: JSEARCH_API_KEY not set โ€” only Adzuna fallback available")
work_list = slugs if slugs else [
slug for slug, s in state["occupations"].items()
if s.get("package") == "done" and s.get("evidence") not in ("done",)
]
if not slugs:
# White collar first: computer-based professions get their market
# evidence before service/blue collar (manifest "collar", p6_collar.py).
_prio = {"white": 0, "service": 1, "blue": 2}
# Homepage-Flaggschiffe zuerst (Landingpage zeigt deren Quellen-Mix live)
_featured = {"artificial-intelligence-engineer": -2, "lawyer": -1,
"database-administrator": -1, "web-designer": -1}
def _collar(slug):
try:
m = json.load(open(os.path.join(BASE, "skills", slug, "manifest.json"),
encoding="utf-8"))
return _prio.get(m.get("collar"), 3)
except (OSError, ValueError):
return 3
work_list.sort(key=lambda s: (_featured.get(s, 0), _collar(s), s))
qa_consecutive_fail = 0
done = 0
for slug in work_list:
if limit and done >= limit:
break
occ_state = progress.occ(state, slug)
if occ_state.get("evidence") == "done":
continue
label, alts = occ_map.get(slug, (None, []))
if not label:
manifest_path = os.path.join(BASE, "skills", slug, "manifest.json")
if os.path.exists(manifest_path):
label = (json.load(open(manifest_path, encoding="utf-8"))
.get("title", slug.replace("-", " ")))
alts = []
else:
occ_state["evidence"] = "failed"
occ_state["error"] = "label not found"
progress.save(state)
continue
try:
# --- FETCH ---
occ_state["evidence"] = "fetching"
progress.save(state)
ads_file = os.path.join(BASE, "data", "raw", "jobs", f"{slug}_ads.json")
if not os.path.exists(ads_file):
ads = _fetch_ads(state, slug, label, alts, api_key, adzuna_id, adzuna_key)
if len(ads) < 5:
occ_state["evidence"] = "failed"
occ_state["error"] = f"thin market ({len(ads)} ads)"
progress.save(state)
print(f"SKIP {slug}: thin market ({len(ads)} ads)")
continue
with open(ads_file, "w", encoding="utf-8", newline="\n") as f:
json.dump(ads, f, ensure_ascii=False, indent=1)
else:
ads = json.load(open(ads_file, encoding="utf-8"))
# --- RELEVANCE GATE (phase 2a): raw ads stay untouched on disk;
# only gated-relevant ads reach extraction/store/aggregate ---
import ad_gate
ads = ad_gate.gate_ads(slug, ads)[0]
if len(ads) < 5:
occ_state["evidence"] = "failed"
occ_state["error"] = f"thin market after gate ({len(ads)} relevant ads)"
progress.save(state)
print(f"SKIP {slug}: thin market after gate ({len(ads)} ads)")
continue
gated_ads_file = os.path.join(BASE, "data", "gated", f"{slug}_relevant.json")
with open(gated_ads_file, "w", encoding="utf-8", newline="\n") as f:
json.dump(ads, f, ensure_ascii=False, indent=1)
occ_state["ads"] = len(ads)
progress.save(state)
# --- EXTRACT ---
occ_state["evidence"] = "extracting"
progress.save(state)
ext_file = os.path.join(BASE, "data", "evidence", f"{slug}.jsonl")
os.makedirs(os.path.dirname(ext_file), exist_ok=True)
if not os.path.exists(ext_file) or os.path.getsize(ext_file) == 0:
ret = subprocess.run(
[sys.executable,
os.path.join(BASE, "pipeline", "extract_local.py"),
"--ads", gated_ads_file, "--out", ext_file],
capture_output=True, text=True)
# exit 0 = clean; exit 1 = >10% fail rate (still usable); >1 = fatal
if ret.returncode > 1:
raise RuntimeError(
f"extract_local exit {ret.returncode}: {ret.stderr[-300:]}")
extractions = [json.loads(l) for l in
open(ext_file, encoding="utf-8") if l.strip()]
if not extractions:
occ_state["evidence"] = "failed"
occ_state["error"] = "extraction: 0 records"
progress.save(state)
continue
# --- STORE + AGGREGATE ---
occ_state["evidence"] = "aggregating"
progress.save(state)
cn2 = connect()
ads_dict = {a["job_id"]: a for a in ads}
n_jobs, n_ent = p3b.store_for_occupation(cn2, ads_dict, extractions, slug)
p3c.aggregate_for_occupation(cn2, slug, p2_generate.SKILLS_DIR)
cn2.close()
# --- QA ---
qa_ret = subprocess.run(
[sys.executable,
os.path.join(BASE, "pipeline", "qa_sample.py"),
"--extractions", ext_file, "--ads", ads_file, "--rate", "0.02"],
capture_output=True, text=True)
if qa_ret.returncode == 1:
qa_consecutive_fail += 1
print(f"QA warn {slug}: {qa_ret.stdout.strip()}")
if qa_consecutive_fail >= 2:
raise RuntimeError(
"QA harness failed twice in a row โ€” systemic problem, stopping")
else:
qa_consecutive_fail = 0
occ_state["evidence"] = "done"
progress.save(state)
done += 1
print(f"evidence done: {slug} ({n_jobs} jobs, {n_ent} entities)")
except progress.BudgetExhausted:
raise
except Exception as exc:
occ_state["evidence"] = "failed"
occ_state["error"] = str(exc)[:200]
progress.save(state)
print(f"FAIL evidence {slug}: {exc}")
if "systemic" in str(exc):
break
print(f"stage_evidence: {done} completed")
def stage_tier(state):
"""Rank occupations for depth staging: tier 1 = TOP_TIER_SIZE by ads collected.
Considers only occupations with evidence='done'. Updates progress.json.
Pure bookkeeping โ€” no API calls.
"""
ranked = sorted(
[(slug, s.get("ads", 0))
for slug, s in state["occupations"].items()
if s.get("evidence") == "done"],
key=lambda x: -x[1]
)
for i, (slug, _ads) in enumerate(ranked):
state["occupations"][slug]["tier"] = 1 if i < TOP_TIER_SIZE else 3
n1 = min(len(ranked), TOP_TIER_SIZE)
n3 = max(0, len(ranked) - TOP_TIER_SIZE)
print(f"stage_tier: {n1} tier-1, {n3} tier-3 occupations ranked")
progress.save(state)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--stage", required=True,
choices=["packages", "evidence", "tier"])
ap.add_argument("--slugs", default="")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args()
# Single-instance lock (atomic O_CREAT|O_EXCL prevents race).
# Only the venv Python is the legitimate runner โ€” system Python impostor exits.
lock_path = os.path.join(BASE, "data", f"batch_{args.stage}.lock")
legitimate_exe = os.path.join(BASE, ".venv", "Scripts", "python.exe")
my_exe = os.path.abspath(sys.executable)
if my_exe.lower() != os.path.abspath(legitimate_exe).lower():
print(f"NOT_VENV: this is {my_exe}, not the venv Python. Exiting immediately.")
sys.exit(3)
state = progress.load()
try:
if args.stage == "packages":
stage_packages(state, args.limit)
elif args.stage == "evidence":
slugs = [s for s in args.slugs.split(",") if s] or None
stage_evidence(state, slugs, args.limit)
else:
stage_tier(state)
except progress.BudgetExhausted as exc:
print(f"STOP: {exc}")
sys.exit(2)
finally:
progress.save(state)
if __name__ == "__main__":
main()