Files
skillfactor-pipeline/pipeline/qa_sample.py

103 lines
4.2 KiB
Python
Raw Permalink Blame History

"""QA harness — 2 % spot-check sample per extraction batch.
Mechanical checks run here; faithfulness (does the extraction match the ad?)
is judged by Claude reading the generated review file. Deviations from
QUALITY_BAR.md ("Extraction quality" section) are logged, not auto-fixed.
Usage:
python pipeline/qa_sample.py --extractions data/evidence/<slug>.jsonl \
--ads data/raw/jobs/<slug>_ads.json [--rate 0.02] [--seed <slug>]
Output:
docs/qa/<batch>-sample.md ad text + extraction side-by-side for review
docs/qa/<batch>-issues.log mechanical deviations (one line each)
Deterministic sampling (seeded by batch name) so re-runs pick the same
records and reviews stay comparable.
"""
import argparse
import json
import os
import random
import sys
sys.path.insert(0, os.path.dirname(__file__))
from extract_local import ARRAY_FIELDS, SENIORITY
BASE = os.path.join(os.path.dirname(__file__), "..")
QA_DIR = os.path.join(BASE, "docs", "qa")
def mechanical_issues(rec):
issues = []
hard = {x.lower() for x in rec.get("hard_skills", [])}
soft = {x.lower() for x in rec.get("soft_skills", [])}
tools = {x.lower() for x in rec.get("tools", [])}
if hard & soft:
issues.append(f"cross-field dupe hard/soft: {sorted(hard & soft)}")
if hard & tools:
issues.append(f"cross-field dupe hard/tools: {sorted(hard & tools)}")
if rec.get("seniority") not in SENIORITY:
issues.append(f"invalid seniority {rec.get('seniority')!r}")
for field, (max_items, max_len) in ARRAY_FIELDS.items():
vals = rec.get(field, [])
if len(vals) > max_items:
issues.append(f"{field}: {len(vals)} items > {max_items}")
for v in vals:
if "<EFBFBD>" in v or len(v) > max_len:
issues.append(f"{field}: bad item {v!r}")
# lowercase rule: only flag all-caps sentences, products are fine
if v.isupper() and len(v) > 6:
issues.append(f"{field}: shouting {v!r}")
return issues
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--extractions", required=True)
ap.add_argument("--ads", required=True)
ap.add_argument("--rate", type=float, default=0.02)
ap.add_argument("--seed", default=None, help="defaults to extractions filename")
args = ap.parse_args()
batch = os.path.splitext(os.path.basename(args.extractions))[0]
rng = random.Random(args.seed or batch)
recs = [json.loads(l) for l in open(args.extractions, encoding="utf-8") if l.strip()]
ads = {a["job_id"]: a for a in json.load(open(args.ads, encoding="utf-8"))}
n = max(1, round(len(recs) * args.rate))
sample = rng.sample(recs, min(n, len(recs)))
os.makedirs(QA_DIR, exist_ok=True)
issues_total = 0
review = [f"# QA sample — {batch}", "",
f"{len(sample)} of {len(recs)} records ({args.rate:.0%}). "
"Judge each extraction against QUALITY_BAR.md 'Extraction quality': "
"faithful, no invented items, plausible seniority.", ""]
with open(os.path.join(QA_DIR, f"{batch}-issues.log"), "w", encoding="utf-8") as log:
for rec in sample:
ad = ads.get(rec["job_id"], {})
probs = mechanical_issues(rec)
issues_total += len(probs)
for p in probs:
log.write(f"{rec['job_id']}\t{p}\n")
review += [f"## {ad.get('title', rec['job_id'])} ({ad.get('country','?')})", "",
"**Ad (first 2000 chars):**", "",
"> " + (ad.get("description", "AD TEXT MISSING")[:2000]
).replace("\n", "\n> "), "",
"**Extraction:**", "", "```json",
json.dumps({k: v for k, v in rec.items() if k != "job_id"},
ensure_ascii=False, indent=1),
"```", ""]
if probs:
review += ["**Mechanical issues:** " + "; ".join(probs), ""]
out = os.path.join(QA_DIR, f"{batch}-sample.md")
open(out, "w", encoding="utf-8").write("\n".join(review))
print(f"{len(sample)} sampled, {issues_total} mechanical issues -> {out}")
if issues_total > len(sample): # more than 1 issue per record on average
sys.exit(1)
if __name__ == "__main__":
main()