Files
skillfactor-pipeline/pipeline/p4c_industry_index.py
skillfactor-pipeline af05383bb1 chore: v1 baseline - full 3,039-package catalog with AI-skill enrichment and O*NET backfill
Snapshot before the quality program (relevance gates, tiered mapping,
QA linter). v1 is the immutable before/after reference; evidence crawl
was at ~175/3039 occupations when tagged.

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

285 lines
15 KiB
Python

"""Phase 4c — industry-navigation index in Gitea.
Creates (or updates) one repo: skills-core/by-industry
Folder tree in the repo:
/
├── README.md (top-level: all industries as links)
├── it-technology/
│ ├── README.md (sub-categories)
│ ├── professionals/ → README.md (job list + links to skills-core)
│ └── technicians/ → README.md
├── healthcare/
│ └── ...
└── ...
Every skill package is referenced by a Markdown link that points to
{GITEA_URL}/skills-core/{slug}/src/branch/main/SKILL.md
which renders directly in Gitea's DOC mode.
Run: python pipeline/p4c_industry_index.py
Safe to re-run (idempotent via force-push).
"""
import json, os, shutil, subprocess, tempfile, time
import requests
from dotenv import load_dotenv
BASE = os.path.join(os.path.dirname(__file__), "..")
SKILLS = os.path.join(BASE, "skills")
load_dotenv(os.path.join(BASE, ".env"))
GITEA_URL = os.environ["GITEA_URL"].rstrip("/")
TOKEN = os.environ["GITEA_TOKEN"]
API = f"{GITEA_URL}/api/v1"
HDR = {"Authorization": f"token {TOKEN}"}
# ─── ISCO → (industry_slug, industry_label, sub_slug, sub_label) ──────────────
# Major group (1st digit) → default industry for uncategorised
_MAJOR_INDUSTRY = {
"0": ("military-defense", "Military & Defense"),
"1": ("management", "Management & Leadership"),
"4": ("administration", "Administration & Office"),
"5": ("services-sales", "Services & Sales"),
"6": ("agriculture", "Agriculture & Environment"),
"7": ("trades-crafts", "Trades & Crafts"),
"8": ("production-logistics","Production & Logistics"),
"9": ("elementary", "Elementary Occupations"),
}
# 2-digit codes → (industry_slug, industry_label, sub_slug, sub_label)
_2DIGIT = {
# Professionals (2x)
"21": ("engineering-science", "Engineering & Science", "professionals", "Professionals"),
"22": ("healthcare", "Healthcare & Medicine", "professionals", "Professionals"),
"23": ("education", "Education & Training", "professionals", "Professionals"),
"24": ("business-finance-law", "Business, Finance & Law", "professionals", "Professionals"),
"25": ("it-technology", "IT & Technology", "professionals", "Professionals"),
"26": ("social-arts-culture", "Social Science, Arts & Culture", "professionals", "Professionals"),
# Technicians (3x)
"31": ("engineering-science", "Engineering & Science", "technicians", "Technicians & Associates"),
"32": ("healthcare", "Healthcare & Medicine", "support", "Healthcare Support"),
"33": ("business-finance-law", "Business, Finance & Law", "support", "Business Support"),
"34": ("social-arts-culture", "Social Science, Arts & Culture", "technicians", "Technicians & Associates"),
"35": ("it-technology", "IT & Technology", "technicians", "Technicians & Support"),
# Management sub-groups
"11": ("management", "Management & Leadership", "chief-executives", "Chief Executives & Legislators"),
"12": ("management", "Management & Leadership", "administrative", "Administrative & Commercial"),
"13": ("management", "Management & Leadership", "production", "Production & Specialised"),
"14": ("management", "Management & Leadership", "hospitality", "Hospitality, Retail & Services"),
# Administration
"41": ("administration", "Administration & Office", "general-office", "General & Keyboard Clerks"),
"42": ("administration", "Administration & Office", "customer-service", "Customer Services Clerks"),
"43": ("administration", "Administration & Office", "numerical", "Numerical & Material Recording"),
"44": ("administration", "Administration & Office", "other-clerical", "Other Clerical Support"),
# Services & Sales
"51": ("services-sales", "Services & Sales", "personal-services","Personal Service Workers"),
"52": ("services-sales", "Services & Sales", "sales", "Sales Workers"),
"53": ("services-sales", "Services & Sales", "personal-care", "Personal Care Workers"),
"54": ("services-sales", "Services & Sales", "protective", "Protective Services Workers"),
# Agriculture
"61": ("agriculture", "Agriculture & Environment", "crop-farming", "Crop Farming"),
"62": ("agriculture", "Agriculture & Environment", "animal-farming", "Animal Farming & Fisheries"),
"63": ("agriculture", "Agriculture & Environment", "subsistence", "Subsistence Farming"),
# Trades
"71": ("trades-crafts", "Trades & Crafts", "building", "Building & Construction"),
"72": ("trades-crafts", "Trades & Crafts", "metal-machinery", "Metal, Machinery & Related"),
"73": ("trades-crafts", "Trades & Crafts", "handicraft", "Handicraft & Printing"),
"74": ("trades-crafts", "Trades & Crafts", "electrical", "Electrical & Electronic"),
"75": ("trades-crafts", "Trades & Crafts", "food-processing", "Food Processing & Related"),
# Production & Logistics
"81": ("production-logistics", "Production & Logistics", "mining-machinery", "Mining & Building Machinery"),
"82": ("production-logistics", "Production & Logistics", "process-operators","Process Plant Operators"),
"83": ("production-logistics", "Production & Logistics", "transport", "Transport & Logistics"),
# Military
"00": ("military-defense", "Military & Defense", "commissioned", "Commissioned Officers"),
"01": ("military-defense", "Military & Defense", "other-ranks", "Non-Commissioned Officers & Other Ranks"),
# Elementary
"91": ("elementary", "Elementary Occupations", "cleaning", "Cleaning & Helpers"),
"92": ("elementary", "Elementary Occupations", "agriculture-elem", "Agricultural Labourers"),
"93": ("elementary", "Elementary Occupations", "production-elem", "Production Labourers"),
"94": ("elementary", "Elementary Occupations", "food-elem", "Food Preparation Assistants"),
"96": ("elementary", "Elementary Occupations", "refuse", "Refuse & Street Services"),
}
def classify(isco_group: str):
"""Return (ind_slug, ind_label, sub_slug, sub_label) for an ISCO 4-digit code."""
code = str(isco_group or "").strip().replace(".", "").zfill(4)
two = code[:2]
one = code[:1]
if two in _2DIGIT:
return _2DIGIT[two]
if one in _MAJOR_INDUSTRY:
isl, ilbl = _MAJOR_INDUSTRY[one]
return (isl, ilbl, "general", "General")
return ("other", "Other Occupations", "general", "General")
# ─── Collect all packages ─────────────────────────────────────────────────────
def load_packages():
"""Yield (slug, title, isco_group) for every package with a manifest."""
for slug in sorted(os.listdir(SKILLS)):
mpath = os.path.join(SKILLS, slug, "manifest.json")
if not os.path.isfile(mpath):
continue
try:
m = json.load(open(mpath, encoding="utf-8"))
yield slug, m.get("title", slug), m.get("ids", {}).get("isco_group", "")
except Exception:
continue
# ─── Build folder tree in a temp dir ─────────────────────────────────────────
GITEA_SKILL_URL = f"{GITEA_URL}/skills-core/{{slug}}/src/branch/main/SKILL.md"
# Root README groups industries by collar class — the catalog's primary
# audience is computer-based (white-collar) work, so it leads.
COLLAR_SECTIONS = [
("💻 White collar — computer-based work",
"The core audience: professions where the computer is the primary work tool.",
["it-technology", "business-finance-law", "management", "healthcare",
"engineering-science", "education", "social-arts-culture", "administration"]),
("🛎️ Service & sales",
"Customer-facing work — partly digital (POS, CRM, booking systems).",
["services-sales"]),
("🔧 Blue collar — manual & field work",
"Hands-on professions; packages exist for completeness of the catalog.",
["trades-crafts", "production-logistics", "agriculture",
"military-defense", "elementary", "other"]),
]
INDUSTRY_ORDER = [i for _, _, inds in COLLAR_SECTIONS for i in inds]
def build_tree(packages):
"""Build the full directory structure and return the temp dir path."""
# groups: { ind_slug: { label, subs: { sub_slug: { label, items: [(slug,title)] } } } }
groups = {}
for slug, title, isco in packages:
ind_s, ind_l, sub_s, sub_l = classify(isco)
g = groups.setdefault(ind_s, {"label": ind_l, "subs": {}})
s = g["subs"].setdefault(sub_s, {"label": sub_l, "items": []})
s["items"].append((slug, title))
tmp = tempfile.mkdtemp(prefix="sfind_")
# ── Root README (grouped by collar class, white collar first) ───────────
root_md = ["# skillfactor — Browse by Industry\n",
"Navigate skill packages by sector. Each folder below opens a sub-category "
"listing with direct links to the full skill packages.\n"]
def _ind_row(ind_s):
g = groups[ind_s]
nsubs = len(g["subs"])
ntotal = sum(len(s["items"]) for s in g["subs"].values())
return f"| [{g['label']}]({ind_s}/) | {nsubs} | {ntotal} |\n"
listed = set()
for title, note, industries in COLLAR_SECTIONS:
present = [i for i in industries if i in groups]
if not present:
continue
n_section = sum(sum(len(s["items"]) for s in groups[i]["subs"].values())
for i in present)
root_md += [f"\n## {title}\n\n", f"{note} **{n_section} packages.**\n\n",
"| Industry | Sub-categories | Packages |\n", "|---|---|---|\n"]
for ind_s in present:
root_md.append(_ind_row(ind_s))
listed.add(ind_s)
# Any industry not covered by a section
rest = [i for i in sorted(groups) if i not in listed]
if rest:
root_md += ["\n## Further\n\n",
"| Industry | Sub-categories | Packages |\n", "|---|---|---|\n"]
root_md += [_ind_row(i) for i in rest]
open(os.path.join(tmp, "README.md"), "w", encoding="utf-8", newline="\n").writelines(root_md)
# ── Per-industry folder ──────────────────────────────────────────────────
for ind_s, g in groups.items():
ind_dir = os.path.join(tmp, ind_s)
os.makedirs(ind_dir, exist_ok=True)
ind_md = [f"# {g['label']}\n\n",
f"[← All industries](../README.md)\n\n",
"| Sub-category | Packages |\n",
"|---|---|\n"]
for sub_s in sorted(g["subs"]):
s = g["subs"][sub_s]
ind_md.append(f"| [{s['label']}]({sub_s}/) | {len(s['items'])} |\n")
open(os.path.join(ind_dir, "README.md"), "w", encoding="utf-8", newline="\n").writelines(ind_md)
# ── Per-sub-category folder ──────────────────────────────────────────
for sub_s, s in g["subs"].items():
sub_dir = os.path.join(ind_dir, sub_s)
os.makedirs(sub_dir, exist_ok=True)
items_sorted = sorted(s["items"], key=lambda x: x[1])
sub_md = [f"# {g['label']}{s['label']}\n\n",
f"[← {g['label']}](../README.md) · [← All industries](../../README.md)\n\n",
f"{len(items_sorted)} skill packages in this category.\n\n",
"| Occupation | Skill Package |\n",
"|---|---|\n"]
for slug, title in items_sorted:
skill_url = GITEA_SKILL_URL.format(slug=slug)
repo_url = f"{GITEA_URL}/skills-core/{slug}"
sub_md.append(f"| {title.title()} | [SKILL.md]({skill_url}) · [repo]({repo_url}) |\n")
open(os.path.join(sub_dir, "README.md"), "w", encoding="utf-8", newline="\n").writelines(sub_md)
return tmp
# ─── Gitea helpers ────────────────────────────────────────────────────────────
def ensure_repo(org, name, description=""):
r = requests.post(f"{API}/orgs/{org}/repos", headers=HDR, timeout=60,
json={"name": name, "description": description[:255],
"private": False, "default_branch": "main", "auto_init": False})
if r.status_code in (200, 201):
print(f"repo created: {org}/{name}")
elif r.status_code == 409:
print(f"repo exists: {org}/{name}")
else:
raise RuntimeError(f"POST /orgs/{org}/repos -> {r.status_code}: {r.text[:200]}")
def push_dir(org, repo, src_dir, message):
url = GITEA_URL.replace("://", f"://gitadmin:{TOKEN}@") + f"/{org}/{repo}.git"
tmp = tempfile.mkdtemp(prefix="sfpush_")
try:
shutil.copytree(src_dir, tmp, dirs_exist_ok=True)
def g(*a): subprocess.run(["git", *a], cwd=tmp, check=True, capture_output=True, text=True)
g("init", "-b", "main")
g("config", "user.name", "skillfactor-pipeline")
g("config", "user.email", "pipeline@noreply.zeiterfassung.cloud")
g("add", "-A")
g("commit", "-m", message)
g("push", "--force", url, "main")
print(f"pushed {org}/{repo}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
packages = list(load_packages())
print(f"loaded {len(packages)} packages")
tree_dir = build_tree(packages)
try:
# Count generated files
n_files = sum(len(fs) for _, _, fs in os.walk(tree_dir))
print(f"generated {n_files} files in {tree_dir}")
ensure_repo("skills-core", "by-industry",
"Browse skill packages by industry and sub-category")
push_dir("skills-core", "by-industry", tree_dir,
f"docs: industry navigation index ({len(packages)} packages)")
finally:
shutil.rmtree(tree_dir, ignore_errors=True)
if __name__ == "__main__":
main()