Files
skillfactor-pipeline/pipeline/p2_generate.py
skillfactor-pipeline 5dfef33f0e feat(gen): SKILL.md quality hardening + reviewed overlay layer
- intro: cached gemma3 rewrite to one clean paragraph (raw ESCO stays the
  provenance reference); overlays/<slug>/intro.md wins when present
- competences: full essential list, the silent essential[:15] alphabetical
  cut is gone
- hot technologies: derived by p3c from post-gate market ranking (omitted
  below 30 gated ads), no longer the alphabetical O*NET dump
- standardized O*NET proxy caveat for non-exact crosswalk matches
- overlays/<slug>/workflow.md preserves curated core workflows across
  regeneration (flagship overlay included)

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

449 lines
18 KiB
Python

"""Phase 2 — deterministically generate ~100 core skill packages (no LLM).
Selection: knowledge-work occupations (management, business, IT, HR, finance,
marketing, support) = curated ISCO-08 prefixes, restricted to ESCO occupations
that have an O*NET crosswalk match (so every package gets tasks + tools).
The recruiter ("recruitment consultant", O*NET 13-1071) is always included.
Output per occupation: skills/<slug>/SKILL.md + references/ + manifest.json
Everything is regenerated from MSSQL on each run (idempotent).
"""
import json
import os
import re
import sys
import unicodedata
from datetime import date
sys.path.insert(0, os.path.dirname(__file__))
from db import connect
SKILLS_DIR = os.path.join(os.path.dirname(__file__), "..", "skills")
OVERLAYS_DIR = os.path.join(os.path.dirname(__file__), "..", "overlays")
REWRITE_CACHE = os.path.join(os.path.dirname(__file__), "..", "data", "rewrites")
def clean_intro(slug, raw_text):
"""Phase 2d: one grammatically clean English paragraph instead of raw
ESCO text. gemma3-rewritten once, cached in data/rewrites/<slug>.intro.txt
(re-runs cost nothing); falls back to the raw text when Ollama is
unreachable. The raw ESCO original stays reachable via the ESCO URI in
the sources footer."""
if not raw_text:
return raw_text
os.makedirs(REWRITE_CACHE, exist_ok=True)
cache = os.path.join(REWRITE_CACHE, f"{slug}.intro.txt")
if os.path.exists(cache):
cached = open(cache, encoding="utf-8").read().strip()
if cached:
return cached
try:
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
url = os.environ.get("OLLAMA_URL", "").rstrip("/")
model = os.environ.get("OLLAMA_MODEL", "")
if not url or not model:
return raw_text
prompt = (
"Rewrite the following occupation description as ONE grammatically "
"correct, clear English paragraph. Fix subject-verb agreement and "
"awkward phrasing. Preserve every fact; add nothing; remove "
"nothing substantive. Output only the paragraph.\n\n" + raw_text[:1500])
r = requests.post(f"{url}/api/generate", json={
"model": model, "prompt": prompt, "stream": False,
"options": {"temperature": 0, "num_ctx": 2048, "num_predict": 400},
}, timeout=120)
r.raise_for_status()
text = (r.json().get("response") or "").strip()
# sanity: rewrite must stay a single plausible paragraph
if 80 <= len(text) <= max(400, int(len(raw_text) * 1.6)) and "\n\n" not in text:
open(cache, "w", encoding="utf-8", newline="\n").write(text + "\n")
return text
except Exception:
pass
return raw_text
def apply_overlays(slug, text):
"""Reviewed manual curation that must SURVIVE regeneration (phase 2d).
overlays/<slug>/intro.md -> replaces the intro paragraph
overlays/<slug>/workflow.md -> replaces the '## Core workflow' body"""
odir = os.path.join(OVERLAYS_DIR, slug)
if not os.path.isdir(odir):
return text
intro_p = os.path.join(odir, "intro.md")
if os.path.isfile(intro_p):
intro = open(intro_p, encoding="utf-8").read().strip()
text = re.sub(r"(?ms)(^---\n.*?\n---\n\n# [^\n]+\n\n).*?(?=\n\n## )",
lambda m: m.group(1) + intro, text, count=1)
wf_p = os.path.join(odir, "workflow.md")
if os.path.isfile(wf_p):
wf = open(wf_p, encoding="utf-8").read().strip()
text = re.sub(r"(?ms)(^## Core workflow\n\n).*?(?=\n\n## )",
lambda m: m.group(1) + wf, text, count=1)
return text
TARGET_COUNT = 100
# ISCO-08 prefixes for knowledge work (management, business, IT, HR, finance,
# marketing, support). HR/recruiting lives in 242 (2423) and 333 (3333).
ISCO_PREFIXES = (
"11", "12", "13", # chief executives, managers
"241", "242", "243", # finance, admin/HR, sales & marketing professionals
"251", "252", # software/ICT professionals
"261", # legal (contracts) — knowledge work
"331", "332", "333", "334", # business associate professionals
"351", "352", # ICT technicians/support
"422", # client information workers (support)
)
MUST_INCLUDE_URIS = [
# recruitment consultant (the deep-dive occupation of phase 3)
"recruitment consultant",
]
ONET_ATTRIBUTION = (
"This package includes information from the O*NET Database (v30.3) by the "
"U.S. Department of Labor, Employment and Training Administration "
"(USDOL/ETA), CC BY 4.0. skillfactor is not endorsed by USDOL/ETA. "
"ESCO data (v1.2.1) (c) European Union, used per the ESCO download "
"conditions: https://esco.ec.europa.eu/en/use-esco/download"
)
def slugify(label: str) -> str:
s = unicodedata.normalize("NFKD", label).encode("ascii", "ignore").decode()
s = re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")
return s[:80]
def q(cn, sql, *params):
cur = cn.cursor()
cur.execute(sql, *params)
cols = [c[0] for c in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
def all_occupations(cn):
"""All 3039 ESCO occupations with best O*NET match (LEFT JOIN; onet_id is None if
no crosswalk entry). Used by batch_run.py stage_packages for the full catalog."""
rows = q(cn, """
SELECT o.concept_uri, o.preferred_label, o.alt_labels, o.description,
o.definition, o.isco_group, o.code,
cw.onet_id, cw.onet_title, cw.match_type,
ig.preferred_label AS isco_label
FROM esco_occupation o
LEFT JOIN crosswalk_esco_onet cw
ON cw.esco_uri = o.concept_uri AND cw.match_type IN ('exactMatch','closeMatch')
LEFT JOIN esco_isco_group ig ON ig.code = o.isco_group
""")
best = {}
for r in rows:
k = r["concept_uri"]
if k not in best:
best[k] = (None, r)
elif r["onet_id"] is not None:
existing_rank, _ = best[k]
new_rank = (0 if r["match_type"] == "exactMatch" else 1, r["onet_id"])
if existing_rank is None or new_rank < existing_rank:
best[k] = (new_rank, r)
return [v[1] for v in best.values()]
def pick_occupations(cn):
"""Deterministic candidate list: ESCO occupation + best O*NET match."""
rows = q(cn, """
SELECT o.concept_uri, o.preferred_label, o.alt_labels, o.description,
o.definition, o.isco_group, o.code,
cw.onet_id, cw.onet_title, cw.match_type,
ig.preferred_label AS isco_label
FROM esco_occupation o
JOIN crosswalk_esco_onet cw ON cw.esco_uri = o.concept_uri
LEFT JOIN esco_isco_group ig ON ig.code = o.isco_group
WHERE cw.match_type IN ('exactMatch','closeMatch')
""")
# best match per ESCO occupation: exactMatch wins, then lowest onet_id (stable)
best = {}
for r in rows:
k = r["concept_uri"]
rank = (0 if r["match_type"] == "exactMatch" else 1, r["onet_id"])
if k not in best or rank < best[k][0]:
best[k] = (rank, r)
cands = [v[1] for v in best.values()]
# bucket per ISCO prefix, then round-robin across buckets so the selection
# spreads over management, business, IT, HR, finance, marketing and support
# instead of filling up with ISCO 11-13 managers alphabetically
buckets = {p: [] for p in ISCO_PREFIXES}
for r in cands:
grp = r["isco_group"] or ""
for p in ISCO_PREFIXES:
if grp.startswith(p):
buckets[p].append(r)
break
for p in buckets:
buckets[p].sort(key=lambda r: (r["isco_group"] or "", r["preferred_label"] or ""))
# force-include the recruiter even if selection window would cut it
selected, seen = [], set()
for r in cands:
if (r["preferred_label"] or "").lower() in MUST_INCLUDE_URIS:
selected.append(r)
seen.add(r["concept_uri"])
while len(selected) < TARGET_COUNT and any(buckets.values()):
for p in ISCO_PREFIXES:
if len(selected) >= TARGET_COUNT:
break
while buckets[p]:
r = buckets[p].pop(0)
if r["concept_uri"] not in seen:
selected.append(r)
seen.add(r["concept_uri"])
break
return selected
def esco_skills_for(cn, uri):
return q(cn, """
SELECT r.relation_type, r.skill_type, s.preferred_label, s.description
FROM esco_occ_skill r
JOIN esco_skill s ON s.concept_uri = r.skill_uri
WHERE r.occupation_uri = ?
ORDER BY CASE r.relation_type WHEN 'essential' THEN 0 ELSE 1 END,
s.preferred_label
""", uri)
def onet_tasks_for(cn, soc):
return q(cn, """
SELECT task, task_type FROM onet_task WHERE soc_code = ?
ORDER BY CASE task_type WHEN 'Core' THEN 0 ELSE 1 END, task_id
""", soc)
def onet_dwas_for(cn, soc):
return q(cn, """
SELECT DISTINCT d.dwa_name
FROM onet_task_dwa td JOIN onet_dwa d ON d.dwa_id = td.dwa_id
WHERE td.soc_code = ? ORDER BY d.dwa_name
""", soc)
def onet_software_for(cn, soc):
return q(cn, """
SELECT example, element_name, hot_technology
FROM onet_software WHERE soc_code = ?
ORDER BY CASE hot_technology WHEN 'Y' THEN 0 ELSE 1 END, element_name, example
""", soc)
def alt_label_list(alt_labels):
if not alt_labels:
return []
return [a.strip() for a in str(alt_labels).splitlines() if a.strip()]
def write(path, content):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
def gen_package(cn, r):
label = r["preferred_label"]
slug = slugify(label)
base = os.path.join(SKILLS_DIR, slug)
alts = alt_label_list(r["alt_labels"])
skills = esco_skills_for(cn, r["concept_uri"])
tasks = onet_tasks_for(cn, r["onet_id"]) if r.get("onet_id") else []
dwas = onet_dwas_for(cn, r["onet_id"]) if r.get("onet_id") else []
software = onet_software_for(cn, r["onet_id"]) if r.get("onet_id") else []
core_tasks = [t["task"] for t in tasks if t["task_type"] == "Core"][:8] or \
[t["task"] for t in tasks][:8]
essential = [s for s in skills if s["relation_type"] == "essential"]
optional = [s for s in skills if s["relation_type"] != "essential"]
trigger_examples = "; ".join(core_tasks[:3]) if core_tasks else \
f"typical {label} responsibilities"
description = (
f"Occupational skill for the role '{label}'"
+ (f" (also: {', '.join(alts[:6])})" if alts else "")
+ f". Use when the user asks for typical {label} work such as: {trigger_examples}"
)
# --- SKILL.md (< 300 lines, progressive disclosure into references/) ---
# Phase 2d: intro = grammatically clean rewrite (cached, gemma3);
# the raw ESCO text stays reachable via the provenance link in the footer.
intro = clean_intro(slug, (r["description"] or r["definition"] or "").strip())
lines = [
"---",
f"name: {slug}",
f"description: {json.dumps(description)}",
"---",
"",
f"# {label.title()}",
"",
intro,
"",
"## Core workflow",
"",
]
for i, t in enumerate(core_tasks, 1):
lines.append(f"{i}. {t}")
lines += [
"",
"## How to use this skill",
"",
"- Read [references/profile.md](references/profile.md) for the occupation profile and scope.",
"- Consult [references/tasks.md](references/tasks.md) for the full task and activity inventory.",
"- Check [references/skills.md](references/skills.md) for essential vs. optional competences.",
"- Check [references/tools.md](references/tools.md) for the software commonly used in this role.",
]
# enrichment files (phase 3) survive regeneration — link them when present
enrichment = [
("references/ai-skills.md", "matched external AI agent skills (tiered, per-source attribution)"),
("references/market.md", "FULL market-evidence report from real job ads (rankings, seniority, titles)"),
("references/usecases.md", "ranked work packages the agent can take over"),
("references/intake.md", "mandatory questions before starting any task"),
("references/quality.md", "acceptance criteria for deliverables"),
("references/glossary.md", "definitions of the key tools and methods"),
("references/literature.md", "standard works per top skill"),
("evals/", "test tasks with pass criteria"),
]
for rel, why in enrichment:
if os.path.exists(os.path.join(base, rel.rstrip("/"))):
lines.append(f"- See [{rel}]({rel}) — {why}.")
lines += [
"",
"## Key competences (essential)",
"",
]
# Phase 2d: FULL essential list — never a silently cut alphabetical prefix.
for s in essential:
lines.append(f"- {s['preferred_label']}")
# "Hot technologies" intentionally NOT emitted here: it is derived from
# post-gate market evidence by p3c (omitted when the corpus is too thin),
# never from the alphabetical O*NET dump.
lines += [
"",
"---",
f"*Sources: ESCO v1.2.1 ({r['concept_uri']}), O*NET 30.3 ({r['onet_id']}). "
"See manifest.json for licensing/attribution.*",
]
# Phase 2d: standardized proxy caveat wherever O*NET data is not an exact match
if r.get("onet_id") and (r.get("match_type") or "").lower() not in ("exact", ""):
lines.append(f"*O*NET nearest match: {r['onet_id']} {r.get('onet_title', '')} "
"(proxy — no exact O*NET occupation exists).*")
lines.append("")
write(os.path.join(base, "SKILL.md"),
apply_overlays(slug, "\n".join(lines)))
# --- references/profile.md ---
prof = [
f"# Occupation profile — {label}",
"",
f"- **ESCO URI:** {r['concept_uri']}",
f"- **ESCO code:** {r['code']}",
f"- **ISCO-08 group:** {r['isco_group']}{r.get('isco_label') or 'n/a'}",
]
if r.get("onet_id"):
prof.append(
f"- **O*NET-SOC:** {r['onet_id']}{r['onet_title']} (match: {r['match_type']})"
)
prof += [
"",
"## Description (ESCO)",
"",
(r["description"] or "").strip(),
]
if r["definition"]:
prof += ["", "## Definition", "", str(r["definition"]).strip()]
if alts:
prof += ["", "## Alternative labels", ""] + [f"- {a}" for a in alts]
write(os.path.join(base, "references", "profile.md"), "\n".join(prof) + "\n")
# --- references/tasks.md ---
tl = [f"# Tasks & work activities — {label}", "",
f"Source: O*NET 30.3, occupation {r['onet_id']} ({r['onet_title']}).", "",
"## Task statements", ""]
for t in tasks:
tl.append(f"- **[{t['task_type'] or 'n/a'}]** {t['task']}")
if dwas:
tl += ["", "## Detailed work activities", ""]
tl += [f"- {d['dwa_name']}" for d in dwas]
write(os.path.join(base, "references", "tasks.md"), "\n".join(tl) + "\n")
# --- references/skills.md ---
sl = [f"# Competences — {label}", "",
f"Source: ESCO v1.2.1 occupation-skill relations ({r['concept_uri']}).", "",
"## Essential", ""]
for s in essential:
sl.append(f"- **{s['preferred_label']}** ({s['skill_type']})")
sl += ["", "## Optional", ""]
for s in optional:
sl.append(f"- {s['preferred_label']} ({s['skill_type']})")
write(os.path.join(base, "references", "skills.md"), "\n".join(sl) + "\n")
# --- references/tools.md ---
ol = [f"# Tools & technology — {label}", "",
f"Source: O*NET 30.3 'Software Skills' for {r['onet_id']}.",
"", "| Software | Category | Hot technology |", "|---|---|---|"]
for s in software:
ol.append(f"| {s['example']} | {s['element_name']} | {'yes' if s['hot_technology'] == 'Y' else ''} |")
write(os.path.join(base, "references", "tools.md"), "\n".join(ol) + "\n")
# --- manifest.json ---
manifest = {
"name": slug,
"title": label,
"version": "0.1.0",
"layer": "core",
"language": "en",
"generated": date.today().isoformat(),
"ids": {
"esco_uri": r["concept_uri"],
"esco_code": r["code"],
"isco_group": r["isco_group"],
"onet_soc": r.get("onet_id"),
"crosswalk_match": r.get("match_type"),
},
"sources": [
{"name": "ESCO", "version": "1.2.1", "url": "https://esco.ec.europa.eu/"},
{"name": "O*NET", "version": "30.3", "url": "https://www.onetcenter.org/", "license": "CC BY 4.0"},
],
"attribution": ONET_ATTRIBUTION,
"counts": {
"tasks": len(tasks),
"dwas": len(dwas),
"skills_essential": len(essential),
"skills_optional": len(optional),
"software": len(software),
},
}
write(os.path.join(base, "manifest.json"), json.dumps(manifest, indent=2) + "\n")
return slug
def main():
cn = connect()
selected = pick_occupations(cn)
print(f"Selected {len(selected)} occupations")
slugs = []
for r in selected:
slugs.append(gen_package(cn, r))
cn.close()
# remove packages from a previous selection that are no longer chosen
import shutil
for d in os.listdir(SKILLS_DIR):
full = os.path.join(SKILLS_DIR, d)
if os.path.isdir(full) and d not in slugs:
shutil.rmtree(full)
print(f"removed stale package: {d}")
print(f"Generated {len(slugs)} packages under skills/")
print("Recruiter included:", any("recruit" in s for s in slugs))
if __name__ == "__main__":
main()