diff --git a/.gitignore b/.gitignore
index 05d9a2e9..f433c017 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,9 @@ __pycache__/
# Local scratch
tmp/
+
+# SE knowledge layer bulk data
+knowledge/data/raw/
+knowledge/data/extracted/
+knowledge/data/se.db*
+
diff --git a/gitea-custom/templates/home.tmpl b/gitea-custom/templates/home.tmpl
index 1106da86..3974d2e4 100644
--- a/gitea-custom/templates/home.tmpl
+++ b/gitea-custom/templates/home.tmpl
@@ -207,6 +207,7 @@
market-evidence crawl started …
running for …
… Ollama extraction requests
+ knowledge layer (Stack Exchange): …
status as of … · computed in … ms · auto-refreshes every 5 min
@@ -447,6 +448,12 @@
setSt("started", s.started_at);
setSt("elapsed", s.elapsed_h + " h " + s.elapsed_m + " min");
setSt("ollama", Number(s.ollama_requests || 0).toLocaleString("en-US"));
+ if (s.se_step) {
+ var seEl = document.getElementById("sf-se-status");
+ if (seEl) seEl.style.display = "";
+ setSt("se_step", s.se_step);
+ setSt("se_detail", s.se_detail ? s.se_detail.slice(0, 90) : "");
+ }
setSt("updated", s.updated_at);
setSt("ms", s.compute_ms);
}).catch(function () {});
diff --git a/knowledge/DECISIONS.md b/knowledge/DECISIONS.md
new file mode 100644
index 00000000..e3f38a65
--- /dev/null
+++ b/knowledge/DECISIONS.md
@@ -0,0 +1,56 @@
+# DECISIONS — Stack-Exchange-Wissensschicht
+
+Autonome Entscheidungen dieser Pipeline, chronologisch.
+
+## D1 — Dump-Version
+`stackexchange_20260331` (archive.org-Suche am 2026-07-09 ergab einen neueren
+Mirror als den im Briefing genannten `stackexchange_20251231`). Dateien liegen
+im Unterordner `stackexchange_20260331/`; alle 6 Ziel-Archive vorhanden,
+zusammen ~929 MB (Meta-Sites bewusst NICHT geladen).
+
+## D2 — Projektort & Repo
+Als Unterprojekt `knowledge/` im bestehenden Pipeline-Repo `C:\dev\skillfactor`
+(Branch `quality/reference-standard`), nicht als separates Repo: die
+Homepage-Quellen (`gitea-custom/templates/home.tmpl`), stats.json-Erzeugung
+und die Provenance-Maschinerie (p3d) leben hier — die SE-Integration greift
+direkt in diese Bausteine.
+
+## D3 — Referenz-Taxonomien
+KEIN Neu-Download: ESCO v1.2.1 und O*NET 30.3 liegen bereits vollständig als
+Tabellen im lokalen MSSQL (`skillfactor`-DB: esco_occupation, esco_skill,
+esco_occ_skill, onet_occupation, onet_task, onet_software, …), geladen von den
+offiziellen Quellen in einer früheren Pipeline-Phase. Wiederverwendung ist
+idempotent und spart Zeit/Bandbreite; Versionen sind in den Paket-Manifests
+dokumentiert.
+
+## D4 — 7z-Entpackung
+`py7zr` (pip) statt System-7-Zip: keine Binary-Abhängigkeit, selektive
+Extraktion nur von `Posts.xml` + `Tags.xml` je Site.
+
+## D5 — LLM-Arbeitsteilung (Kosten/Kontext)
+- Tag→Taxonomie-Mapping: Claude klassifiziert die frequenzstärksten Tags
+ direkt in Batches (Sprachverständnis, Konfidenz + Rationale); der Long-Tail
+ (niedrige Frequenz nahe der 20er-Schwelle) läuft über das lokale
+ gemma3:27b mit Claude-designtem Prompt und wird stichprobengeprüft.
+ Grund: mehrere tausend Tags × Rationale sprengen das Session-Kontextbudget.
+- Q&A-Zusammenfassungen (Schritt 4, ~10×150 Einträge): lokales gemma3
+ (etabliertes Muster der Evidence-Pipeline: Ollama extrahiert, Claude
+ designt Prompt + validiert + macht Stichproben-QA). Läuft detached, GPU
+ wird mit dem laufenden Evidence-Crawl geteilt.
+
+## D6 — Homepage-Integration
+Der Homepage-Code LIEGT im Arbeitsverzeichnis (Live-Template
+`Tools\gitea\custom\templates\home.tmpl`, Spiegel in
+`gitea-custom/templates/`). Integration erfolgt daher direkt (kein
+`homepage/`-Snippet-Ordner nötig): Stack Exchange wird 6. Provenance-Kategorie
+(`stackx`) in p3d/p4/stats.json; der Donut-/Legenden-Anteil bemisst sich
+datenbasiert aus den Content-Items der kompilierten Q&A-Einträge (gleiche
+Zählmethode wie alle anderen Quellen: count_items über die generierten
+Dateien). Für die 10 Eval-Berufe wird das kompilierte Wissen zusätzlich als
+`references/practitioner-qa.md` in die zugehörigen Skill-Pakete gelegt —
+damit ist der Pie-Anteil echt gemessen, nicht angenommen.
+
+## D7 — Live-Fortschritt (Nutzerwunsch 2026-07-09)
+Die Pipeline schreibt `knowledge/data/progress-se.json` nach jedem Schritt;
+der bestehende 5-Minuten-Status-Writer merged das in `assets/status.json`,
+die Homepage-Statusleiste zeigt den SE-Layer-Stand neben dem Crawl-Zähler.
diff --git a/knowledge/README.md b/knowledge/README.md
new file mode 100644
index 00000000..fd09213d
--- /dev/null
+++ b/knowledge/README.md
@@ -0,0 +1,48 @@
+# SkillFactor knowledge layer — Stack Exchange practitioner Q&A
+
+Curated practitioner knowledge from the Stack Exchange data dumps
+(CC-BY-SA 4.0), mapped onto ESCO skills / O*NET occupations and compiled
+into per-profession knowledge files.
+
+**Dump version:** `stackexchange_20260331` (archive.org community mirror —
+newest at build time, see DECISIONS.md D1). Sites: workplace, pm, law,
+money, softwareengineering, datascience (~929 MB compressed).
+
+## Setup
+
+```
+# venv of the parent project is reused
+..\.venv\Scripts\pip install py7zr
+powershell -File scripts\download.ps1 # resumable, skips existing
+```
+
+## Pipeline (idempotent, in order)
+
+```
+python pipeline\se_pipeline.py ingest # 7z -> SQLite (streamed iterparse)
+python pipeline\se_pipeline.py filter # quality gates -> qa_pairs
+python pipeline\se_pipeline.py tags # tag frequencies >= 20 -> CSV
+# -> LLM tag mapping produces mappings\tag_to_skill.csv (see DECISIONS D5)
+python pipeline\se_pipeline.py compile # professions\\knowledge.md + vocabulary.json
+python pipeline\se_pipeline.py verify # consistency checks (exit != 0 on failure)
+```
+
+Every step writes `data/progress-se.json`; the homepage status strip picks
+it up within 5 minutes (see DECISIONS D7).
+
+## Layout
+
+```
+data/raw/ downloaded 7z archives (git-ignored)
+data/extracted/ Posts/Tags/Users.xml per site (git-ignored)
+data/se.db SQLite: questions/answers/users/qa_pairs (git-ignored)
+mappings/ tag_to_skill.csv, unmapped.csv (versioned)
+professions/ /knowledge.md, vocabulary.json (versioned)
+```
+
+## License
+
+Source content: Stack Exchange Network, **CC-BY-SA 4.0** — see
+`ATTRIBUTION.md`. Compiled knowledge files are derivative works and carry
+per-entry attribution (question link, author, license); published derived
+content must remain share-alike.
diff --git a/knowledge/pipeline/se_pipeline.py b/knowledge/pipeline/se_pipeline.py
new file mode 100644
index 00000000..69546ebe
--- /dev/null
+++ b/knowledge/pipeline/se_pipeline.py
@@ -0,0 +1,277 @@
+"""SkillFactor knowledge layer — Stack Exchange practitioner Q&A.
+
+Reproducible pipeline over the archive.org data-dump mirror
+(stackexchange_20260331, CC-BY-SA 4.0). See knowledge/README.md.
+
+CLI:
+ python knowledge/pipeline/se_pipeline.py ingest 7z -> SQLite (streamed)
+ python knowledge/pipeline/se_pipeline.py filter quality gates
+ python knowledge/pipeline/se_pipeline.py tags tag frequency export
+ python knowledge/pipeline/se_pipeline.py compile profession knowledge files
+ python knowledge/pipeline/se_pipeline.py verify consistency checks
+
+Idempotent: existing archives are not re-downloaded (scripts/download.ps1),
+extraction/ingest skip completed sites, summaries are cached per post id.
+Every step updates data/progress-se.json for the homepage live status.
+"""
+import argparse
+import html
+import json
+import os
+import re
+import sqlite3
+import sys
+from datetime import datetime
+from xml.etree import ElementTree as ET
+
+K = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # knowledge/
+RAW = os.path.join(K, "data", "raw")
+EXTRACTED = os.path.join(K, "data", "extracted")
+DB = os.path.join(K, "data", "se.db")
+PROGRESS = os.path.join(K, "data", "progress-se.json")
+
+DUMP_VERSION = "stackexchange_20260331"
+DUMP_URL = "https://archive.org/download/stackexchange_20260331/"
+
+SITES = {
+ "workplace": "workplace.stackexchange.com",
+ "pm": "pm.stackexchange.com",
+ "law": "law.stackexchange.com",
+ "money": "money.stackexchange.com",
+ "softwareengineering": "softwareengineering.stackexchange.com",
+ "datascience": "datascience.stackexchange.com",
+}
+
+MIN_Q_SCORE = 5
+MIN_A_SCORE = 5
+MIN_ANSWER_CHARS = 300
+TAG_MIN_FREQ = 20
+
+
+def progress(step, **info):
+ data = {}
+ if os.path.exists(PROGRESS):
+ try:
+ data = json.load(open(PROGRESS, encoding="utf-8"))
+ except ValueError:
+ data = {}
+ data["step"] = step
+ data["updated_at"] = datetime.now().isoformat(timespec="seconds")
+ data.setdefault("steps", {})[step] = {
+ "at": data["updated_at"], **info}
+ json.dump(data, open(PROGRESS, "w", encoding="utf-8"), indent=1)
+
+
+def strip_html(s):
+ if not s:
+ return ""
+ s = re.sub(r"<(pre|code)[^>]*>(.*?)\1>",
+ lambda m: "\n```\n" + m.group(2) + "\n```\n", s, flags=re.S)
+ s = re.sub(r"
||", "\n", s)
+ s = re.sub(r"<[^>]+>", "", s)
+ return html.unescape(s).strip()
+
+
+def db():
+ cn = sqlite3.connect(DB)
+ cn.execute("PRAGMA journal_mode=WAL")
+ return cn
+
+
+def ensure_schema(cn):
+ cn.executescript("""
+ CREATE TABLE IF NOT EXISTS questions(
+ site TEXT, id INTEGER, title TEXT, body TEXT, tags TEXT,
+ score INTEGER, accepted_answer_id INTEGER, view_count INTEGER,
+ closed INTEGER, owner_id INTEGER, PRIMARY KEY(site, id));
+ CREATE TABLE IF NOT EXISTS answers(
+ site TEXT, id INTEGER, parent_id INTEGER, body TEXT,
+ score INTEGER, owner_id INTEGER, PRIMARY KEY(site, id));
+ CREATE TABLE IF NOT EXISTS users(
+ site TEXT, id INTEGER, display_name TEXT, PRIMARY KEY(site, id));
+ CREATE TABLE IF NOT EXISTS ingest_log(
+ site TEXT PRIMARY KEY, questions INTEGER, answers INTEGER,
+ users INTEGER, at TEXT);
+ CREATE INDEX IF NOT EXISTS ix_answers_parent ON answers(site, parent_id);
+ """)
+
+
+def cmd_ingest():
+ import py7zr
+ cn = db()
+ ensure_schema(cn)
+ done = {r[0] for r in cn.execute("SELECT site FROM ingest_log")}
+ for key, host in SITES.items():
+ if key in done:
+ print(f"{key}: already ingested — skip")
+ continue
+ arc = os.path.join(RAW, f"{host}.7z")
+ if not os.path.exists(arc):
+ print(f"{key}: archive missing ({arc}) — skip")
+ continue
+ xdir = os.path.join(EXTRACTED, key)
+ os.makedirs(xdir, exist_ok=True)
+ if not os.path.exists(os.path.join(xdir, "Posts.xml")):
+ print(f"{key}: extracting Posts/Tags/Users ...")
+ with py7zr.SevenZipFile(arc) as z:
+ names = [n for n in z.getnames()
+ if n in ("Posts.xml", "Tags.xml", "Users.xml")]
+ z.extract(path=xdir, targets=names)
+
+ nq = na = nu = 0
+ qb, ab = [], []
+ for _ev, el in ET.iterparse(os.path.join(xdir, "Posts.xml")):
+ if el.tag != "row":
+ continue
+ pt = el.get("PostTypeId")
+ if pt == "1":
+ qb.append((key, int(el.get("Id")), el.get("Title", ""),
+ strip_html(el.get("Body", "")),
+ el.get("Tags", ""), int(el.get("Score", 0)),
+ int(el.get("AcceptedAnswerId", 0) or 0),
+ int(el.get("ViewCount", 0) or 0),
+ 1 if el.get("ClosedDate") else 0,
+ int(el.get("OwnerUserId", 0) or 0)))
+ nq += 1
+ elif pt == "2":
+ ab.append((key, int(el.get("Id")),
+ int(el.get("ParentId", 0) or 0),
+ strip_html(el.get("Body", "")),
+ int(el.get("Score", 0)),
+ int(el.get("OwnerUserId", 0) or 0)))
+ na += 1
+ el.clear()
+ if len(qb) >= 2000:
+ cn.executemany("INSERT OR REPLACE INTO questions VALUES(?,?,?,?,?,?,?,?,?,?)", qb)
+ qb = []
+ if len(ab) >= 2000:
+ cn.executemany("INSERT OR REPLACE INTO answers VALUES(?,?,?,?,?,?)", ab)
+ ab = []
+ if qb:
+ cn.executemany("INSERT OR REPLACE INTO questions VALUES(?,?,?,?,?,?,?,?,?,?)", qb)
+ if ab:
+ cn.executemany("INSERT OR REPLACE INTO answers VALUES(?,?,?,?,?,?)", ab)
+
+ upath = os.path.join(xdir, "Users.xml")
+ if os.path.exists(upath):
+ ub = []
+ for _ev, el in ET.iterparse(upath):
+ if el.tag == "row":
+ ub.append((key, int(el.get("Id")),
+ el.get("DisplayName", "")))
+ nu += 1
+ el.clear()
+ if len(ub) >= 5000:
+ cn.executemany("INSERT OR REPLACE INTO users VALUES(?,?,?)", ub)
+ ub = []
+ if ub:
+ cn.executemany("INSERT OR REPLACE INTO users VALUES(?,?,?)", ub)
+
+ cn.execute("INSERT OR REPLACE INTO ingest_log VALUES(?,?,?,?,?)",
+ (key, nq, na, nu, datetime.now().isoformat()))
+ cn.commit()
+ print(f"{key}: {nq} questions, {na} answers, {nu} users")
+ progress("ingest", site=key, questions=nq, answers=na)
+ cn.close()
+ progress("ingest-done")
+
+
+def cmd_filter():
+ cn = db()
+ cn.executescript("""
+ DROP TABLE IF EXISTS qa_pairs;
+ CREATE TABLE qa_pairs(
+ site TEXT, q_id INTEGER, title TEXT, q_body TEXT, tags TEXT,
+ q_score INTEGER, view_count INTEGER,
+ a_id INTEGER, a_body TEXT, a_score INTEGER, a_accepted INTEGER,
+ q_owner INTEGER, a_owner INTEGER,
+ PRIMARY KEY(site, q_id, a_id));
+ """)
+ stats = {}
+ for key in SITES:
+ before = cn.execute(
+ "SELECT COUNT(*) FROM questions WHERE site=?", (key,)).fetchone()[0]
+ # accepted + top-scored answer per qualifying question
+ cn.execute("""
+ INSERT OR REPLACE INTO qa_pairs
+ SELECT q.site, q.id, q.title, q.body, q.tags, q.score, q.view_count,
+ a.id, a.body, a.score,
+ CASE WHEN a.id = q.accepted_answer_id THEN 1 ELSE 0 END,
+ q.owner_id, a.owner_id
+ FROM questions q
+ JOIN answers a ON a.site = q.site AND a.parent_id = q.id
+ WHERE q.site = ?
+ AND q.score >= ?
+ AND q.closed = 0
+ AND LENGTH(a.body) >= ?
+ AND (a.id = q.accepted_answer_id OR
+ a.id = (SELECT a2.id FROM answers a2
+ WHERE a2.site = q.site AND a2.parent_id = q.id
+ ORDER BY a2.score DESC, a2.id LIMIT 1))
+ AND EXISTS (SELECT 1 FROM answers ax
+ WHERE ax.site = q.site AND ax.parent_id = q.id
+ AND (ax.score >= ? OR ax.id = q.accepted_answer_id))
+ """, (key, MIN_Q_SCORE, MIN_ANSWER_CHARS, MIN_A_SCORE))
+ after_q = cn.execute(
+ "SELECT COUNT(DISTINCT q_id) FROM qa_pairs WHERE site=?",
+ (key,)).fetchone()[0]
+ after_p = cn.execute(
+ "SELECT COUNT(*) FROM qa_pairs WHERE site=?", (key,)).fetchone()[0]
+ stats[key] = {"questions_raw": before, "questions_kept": after_q,
+ "pairs_kept": after_p}
+ print(f"{key}: {before} raw questions -> {after_q} kept "
+ f"({after_p} Q&A pairs)")
+ cn.commit()
+ cn.close()
+ json.dump(stats, open(os.path.join(K, "data", "filter-stats.json"), "w",
+ encoding="utf-8"), indent=1)
+ progress("filter-done", **{k: v["questions_kept"] for k, v in stats.items()})
+
+
+def cmd_tags():
+ cn = db()
+ out = os.path.join(K, "data", "tags_freq.csv")
+ rows = []
+ for key in SITES:
+ freq = {}
+ for (tags,) in cn.execute(
+ "SELECT tags FROM qa_pairs WHERE site=?", (key,)):
+ for t in re.findall(r"[<|]([^><|]+)[>|]", tags or ""):
+ freq[t] = freq.get(t, 0) + 1
+ for t, n in sorted(freq.items(), key=lambda kv: -kv[1]):
+ if n >= TAG_MIN_FREQ:
+ rows.append((key, t, n))
+ with open(out, "w", encoding="utf-8", newline="\n") as f:
+ f.write("site,tag,count\n")
+ for r in rows:
+ f.write(f"{r[0]},{r[1]},{r[2]}\n")
+ print(f"{len(rows)} tags with freq >= {TAG_MIN_FREQ} -> {out}")
+ per_site = {}
+ for s, _t, _n in rows:
+ per_site[s] = per_site.get(s, 0) + 1
+ print(per_site)
+ progress("tags-done", tags_total=len(rows), **per_site)
+ cn.close()
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("cmd", choices=["ingest", "filter", "tags",
+ "compile", "verify"])
+ args = ap.parse_args()
+ if args.cmd == "ingest":
+ cmd_ingest()
+ elif args.cmd == "filter":
+ cmd_filter()
+ elif args.cmd == "tags":
+ cmd_tags()
+ elif args.cmd == "compile":
+ from se_compile import cmd_compile
+ cmd_compile()
+ elif args.cmd == "verify":
+ from se_verify import cmd_verify
+ cmd_verify()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/knowledge/scripts/download.ps1 b/knowledge/scripts/download.ps1
new file mode 100644
index 00000000..953d0390
--- /dev/null
+++ b/knowledge/scripts/download.ps1
@@ -0,0 +1,22 @@
+# Lädt die 6 Stack-Exchange-Site-Archive vom archive.org-Mirror (resumable).
+# Version: stackexchange_20260331 (neuester Mirror, geprüft 2026-07-09).
+$base = "https://archive.org/download/stackexchange_20260331/stackexchange_20260331"
+$dest = "C:\dev\skillfactor\knowledge\data\raw"
+New-Item -ItemType Directory -Force $dest | Out-Null
+$files = @(
+ "workplace.stackexchange.com.7z",
+ "pm.stackexchange.com.7z",
+ "law.stackexchange.com.7z",
+ "money.stackexchange.com.7z",
+ "softwareengineering.stackexchange.com.7z",
+ "datascience.stackexchange.com.7z"
+)
+$log = "C:\dev\skillfactor\docs\logs\se_download.log"
+foreach ($f in $files) {
+ "$(Get-Date -Format 'HH:mm:ss') start $f" | Out-File $log -Append -Encoding utf8
+ # curl.exe: resumable (-C -), folgt Redirects (-L), still (-sS)
+ & curl.exe -L -sS -C - -o "$dest\$f" "$base/$f" 2>&1 | Out-File $log -Append -Encoding utf8
+ $size = [Math]::Round((Get-Item "$dest\$f").Length / 1MB)
+ "$(Get-Date -Format 'HH:mm:ss') done $f ($size MB)" | Out-File $log -Append -Encoding utf8
+}
+"$(Get-Date -Format 'HH:mm:ss') ALL DOWNLOADS COMPLETE" | Out-File $log -Append -Encoding utf8
diff --git a/pipeline/run-status-writer.ps1 b/pipeline/run-status-writer.ps1
index fcbcf1fa..16e9b154 100644
--- a/pipeline/run-status-writer.ps1
+++ b/pipeline/run-status-writer.ps1
@@ -54,6 +54,12 @@ while ($true) {
$startDt = [datetime]::ParseExact($started, "yyyy-MM-dd HH:mm", $null)
$elapsed = (Get-Date) - $startDt
$sw.Stop()
+ # Stack-Exchange-Wissensschicht: Fortschritt der knowledge-Pipeline
+ $se = $null
+ $seFile = "$dev\knowledge\data\progress-se.json"
+ if (Test-Path $seFile) {
+ try { $se = Get-Content $seFile -Raw -Encoding utf8 | ConvertFrom-Json } catch {}
+ }
$obj = [ordered]@{
done = $done
total = $total
@@ -62,6 +68,11 @@ while ($true) {
elapsed_h = [Math]::Floor($elapsed.TotalHours)
elapsed_m = $elapsed.Minutes
ollama_requests = $ollama
+ se_step = $(if ($se) { $se.step } else { $null })
+ se_updated = $(if ($se) { $se.updated_at } else { $null })
+ se_detail = $(if ($se -and $se.steps -and $se.step -and $se.steps.($se.step)) {
+ ($se.steps.($se.step) | ConvertTo-Json -Compress)
+ } else { $null })
updated_at = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
compute_ms = $sw.ElapsedMilliseconds
}