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
285 lines
15 KiB
Python
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()
|