Files
skillfactor-pipeline/knowledge/pipeline/se_pipeline.py
skillfactor-pipeline 7850baf6bb feat(knowledge): SE practitioner-QA layer scaffold + live progress hook
Dump stackexchange_20260331 (newest mirror), 6 sites, downloads running;
streamed ingest/filter/tags pipeline; status writer + homepage strip show
the layer's build progress every 5 minutes.

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

278 lines
10 KiB
Python

"""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"<br\s*/?>|</p>|</li>", "\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()