Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import re
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import yaml
DEFAULT_PATH = Path("docs/ai/BACKLOG.yaml")
VALID_STATUSES = {"done", "ready", "blocked-external", "deferred"}
VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
ID_PATTERN = re.compile(r"^VR-\d{3}$")
REQUIREMENT_PATTERN = re.compile(r"^(PR|NFR|AC)-\d{3}$")
REQUIRED_TASK_FIELDS = {
"id",
"title",
"status",
"priority",
"requirement_ids",
"depends_on",
"summary",
"acceptance_criteria",
"verification",
"primary_paths",
}
class LedgerError(ValueError):
pass
def load_ledger(path: Path = DEFAULT_PATH) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise LedgerError(f"Taakledger ontbreekt: {path}") from exc
except yaml.YAMLError as exc:
raise LedgerError(f"Ongeldige YAML in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise LedgerError("Taakledger moet een mapping op topniveau zijn")
return payload
def _require_nonempty_list(task: dict[str, Any], field: str) -> None:
value = task.get(field)
if not isinstance(value, list) or not value:
raise LedgerError(f"{task.get('id', '<zonder-id>')}: {field} moet een niet-lege lijst zijn")
if any(not isinstance(item, str) or not item.strip() for item in value):
task_id = task.get("id", "<zonder-id>")
raise LedgerError(f"{task_id}: {field} bevat een lege/niet-tekst waarde")
def validate_ledger(payload: dict[str, Any]) -> list[dict[str, Any]]:
if payload.get("schema_version") != 1:
raise LedgerError("Alleen schema_version 1 wordt ondersteund")
tasks = payload.get("tasks")
if not isinstance(tasks, list) or not tasks:
raise LedgerError("BACKLOG.yaml moet minstens één taak bevatten")
ids: list[str] = []
task_by_id: dict[str, dict[str, Any]] = {}
for raw in tasks:
if not isinstance(raw, dict):
raise LedgerError("Iedere taak moet een mapping zijn")
missing = REQUIRED_TASK_FIELDS - raw.keys()
if missing:
task_id = raw.get("id", "<zonder-id>")
raise LedgerError(f"{task_id}: ontbrekende velden {sorted(missing)}")
task_id = raw["id"]
if not isinstance(task_id, str) or not ID_PATTERN.fullmatch(task_id):
raise LedgerError(f"Ongeldige taak-ID: {task_id!r}")
if task_id in task_by_id:
raise LedgerError(f"Dubbele taak-ID: {task_id}")
if raw["status"] not in VALID_STATUSES:
raise LedgerError(f"{task_id}: ongeldige status {raw['status']!r}")
if raw["priority"] not in VALID_PRIORITIES:
raise LedgerError(f"{task_id}: ongeldige priority {raw['priority']!r}")
if not isinstance(raw["title"], str) or not raw["title"].strip():
raise LedgerError(f"{task_id}: title ontbreekt")
if not isinstance(raw["summary"], str) or len(raw["summary"].strip()) < 20:
raise LedgerError(f"{task_id}: summary is te kort")
for field in ("requirement_ids", "acceptance_criteria", "verification", "primary_paths"):
_require_nonempty_list(raw, field)
for requirement in raw["requirement_ids"]:
if not REQUIREMENT_PATTERN.fullmatch(requirement):
raise LedgerError(f"{task_id}: ongeldige requirement-ID {requirement!r}")
if not isinstance(raw["depends_on"], list) or any(
not isinstance(item, str) for item in raw["depends_on"]
):
raise LedgerError(f"{task_id}: depends_on moet een lijst van IDs zijn")
if task_id in raw["depends_on"]:
raise LedgerError(f"{task_id}: taak mag niet van zichzelf afhangen")
if raw["status"] == "done":
result = raw.get("result")
has_result = (
isinstance(result, dict)
and bool(result.get("completed_at"))
and bool(result.get("note"))
)
if not has_result:
raise LedgerError(
f"{task_id}: done taak vereist result.completed_at en result.note"
)
if raw["status"] == "blocked-external" and not raw.get("external_blocker"):
raise LedgerError(f"{task_id}: blocked-external vereist external_blocker")
ids.append(task_id)
task_by_id[task_id] = raw
for task in tasks:
for dependency in task["depends_on"]:
if dependency not in task_by_id:
raise LedgerError(f"{task['id']}: onbekende dependency {dependency}")
visiting: set[str] = set()
visited: set[str] = set()
def visit(task_id: str, trail: list[str]) -> None:
if task_id in visiting:
cycle = " -> ".join([*trail, task_id])
raise LedgerError(f"Dependencycyclus: {cycle}")
if task_id in visited:
return
visiting.add(task_id)
for dependency in task_by_id[task_id]["depends_on"]:
visit(dependency, [*trail, task_id])
visiting.remove(task_id)
visited.add(task_id)
for task_id in ids:
visit(task_id, [])
for task in tasks:
if task["status"] == "done":
incomplete_dependencies = [
dep for dep in task["depends_on"] if task_by_id[dep]["status"] != "done"
]
if incomplete_dependencies:
raise LedgerError(
f"{task['id']}: done maar dependencies niet done: {incomplete_dependencies}"
)
return tasks
def save_ledger(payload: dict[str, Any], path: Path = DEFAULT_PATH) -> None:
payload["updated_at"] = datetime.now(UTC).date().isoformat()
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=110),
encoding="utf-8",
)
temporary.replace(path)
def executable_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
status_by_id = {task["id"]: task["status"] for task in tasks}
return [
task
for task in tasks
if task["status"] == "ready"
and all(status_by_id[dependency] == "done" for dependency in task["depends_on"])
]
def print_task(task: dict[str, Any], *, verbose: bool = True) -> None:
print(f"{task['id']} [{task['priority']}/{task['status']}] {task['title']}")
if not verbose:
return
print(f"\n{task['summary']}")
if task["depends_on"]:
print(f"\nDependencies: {', '.join(task['depends_on'])}")
print("\nAcceptatiecriteria:")
for item in task["acceptance_criteria"]:
print(f"- {item}")
print("\nVerificatie:")
for item in task["verification"]:
print(f"- {item}")
print("\nPrimaire paden:")
for item in task["primary_paths"]:
print(f"- {item}")
if task.get("external_blocker"):
print(f"\nExterne blokkade: {task['external_blocker']}")
if task.get("result"):
print(f"\nResultaat: {task['result'].get('note', '')}")
def command_validate(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
counts = {status: sum(task["status"] == status for task in tasks) for status in VALID_STATUSES}
executable = executable_tasks(tasks)
print(
"Taakledger geldig: "
f"{len(tasks)} taken; "
+ ", ".join(f"{status}={counts[status]}" for status in sorted(counts))
+ f"; uitvoerbaar={len(executable)}"
)
return 0
def command_next(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
executable = executable_tasks(tasks)
if not executable:
print("Geen uitvoerbare ready taak. Controleer blocked-external en deferred taken.")
return 1
print_task(executable[0], verbose=not args.id_only)
return 0
def command_list(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
selected = [task for task in tasks if not args.status or task["status"] == args.status]
for task in selected:
print_task(task, verbose=False)
return 0
def command_show(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
for task in tasks:
if task["id"] == args.task_id:
print_task(task)
return 0
raise LedgerError(f"Onbekende taak: {args.task_id}")
def command_set(args: argparse.Namespace) -> int:
payload = load_ledger(args.path)
tasks = validate_ledger(payload)
task_by_id = {task["id"]: task for task in tasks}
try:
target = task_by_id[args.task_id]
except KeyError as exc:
raise LedgerError(f"Onbekende taak: {args.task_id}") from exc
if args.status == "done":
incomplete = [dep for dep in target["depends_on"] if task_by_id[dep]["status"] != "done"]
if incomplete:
raise LedgerError(
f"Kan {args.task_id} niet afronden; dependencies niet done: {incomplete}"
)
if not args.note:
raise LedgerError("Status done vereist --note met het geverifieerde resultaat")
target["result"] = {
"completed_at": datetime.now(UTC).date().isoformat(),
"note": args.note.strip(),
}
target.pop("external_blocker", None)
elif args.status == "blocked-external":
if not args.note:
raise LedgerError("blocked-external vereist --note met de exacte externe blokkade")
target["external_blocker"] = args.note.strip()
target.pop("result", None)
else:
target.pop("result", None)
if args.status != "blocked-external":
target.pop("external_blocker", None)
target["status"] = args.status
save_ledger(payload, args.path)
validate_ledger(load_ledger(args.path))
print_task(target, verbose=False)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="VacatureRadar machineleesbare backlog beheren")
parser.add_argument("--path", type=Path, default=DEFAULT_PATH)
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate", help="Valideer schema en dependencygraaf")
validate_parser.set_defaults(func=command_validate)
next_parser = subparsers.add_parser("next", help="Toon de eerste uitvoerbare ready taak")
next_parser.add_argument("--id-only", action="store_true", help="Toon alleen de kopregel")
next_parser.set_defaults(func=command_next)
list_parser = subparsers.add_parser("list", help="Toon taken")
list_parser.add_argument("--status", choices=sorted(VALID_STATUSES))
list_parser.set_defaults(func=command_list)
show_parser = subparsers.add_parser("show", help="Toon één taak volledig")
show_parser.add_argument("task_id")
show_parser.set_defaults(func=command_show)
set_parser = subparsers.add_parser("set", help="Wijzig een taakstatus")
set_parser.add_argument("task_id")
set_parser.add_argument("status", choices=sorted(VALID_STATUSES))
set_parser.add_argument("--note", default="")
set_parser.set_defaults(func=command_set)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except LedgerError as exc:
print(f"Taakledgerfout: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+978
View File
@@ -0,0 +1,978 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import platform
import tracemalloc
from contextlib import contextmanager
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from statistics import mean
from time import perf_counter
from typing import Any
import django
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import connection
from django.utils import timezone
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from apps.jobs.models import Employer, JobPosting, JobSourceAlias, ScoreRun # noqa: E402
from apps.jobs.services.dedupe import find_existing_job # noqa: E402
from apps.jobs.services.normalization import CanonicalJobDraft, normalize_token # noqa: E402
from apps.jobs.services.pipeline import PersistenceContext, process_raw_document # noqa: E402
from apps.jobs.services.scoring import calculate_score, rescore_jobs_with_profiles # noqa: E402
from apps.profiles.models import SearchProfile # noqa: E402
from apps.sources.adapters.registry import registry # noqa: E402
from apps.sources.models import RawDocument, Source, SourceRun # noqa: E402
DEFAULT_DATASET = Path("fixtures/benchmark/quality_benchmark.json")
DEFAULT_PARSER_FIELDS = [
"title",
"employer_name",
"location_text",
"description_text",
"date_posted",
"employment_types",
]
PARSER_EXPECTED_MIN_COVERAGE = 0.90
DEDUPE_EXPECTED_MIN_PRECISION = 0.90
DEDUPE_EXPECTED_MIN_RECALL = 0.90
TOP_N_EXPECTED_MAX_CHURN = 0.35
NFR_007_P95_MS_LIMIT = 1000.0
DEFAULT_TOP_N = 25
QUICK_JOBS = 250
FULL_JOBS = 25_000
PERF_RANKING_JOBS = 120
def _percentile(values: list[float], p: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
index = max(0, math.ceil(len(ordered) * p / 100) - 1)
return ordered[index]
def _to_bool(value: object) -> bool:
return bool(value)
def _value_present(value: object) -> bool:
if value is None:
return False
if isinstance(value, str):
return bool(value.strip())
if isinstance(value, list | tuple | dict | set):
return len(value) > 0
if isinstance(value, bool | int | float | Decimal):
return True
return bool(value)
def _is_supported_file_path(path: Path) -> bool:
if path.exists() and path.is_file():
return True
raise FileNotFoundError(f"Fixturebestand niet gevonden: {path}")
@dataclass
class BenchmarkArtifact:
profile_id: int | None = None
user_id: int | None = None
source_id: int | None = None
source_run_id: int | None = None
job_ids: list[int] | None = None
raw_ids: list[int] | None = None
alias_ids: list[int] | None = None
scorerun_ids: list[int] | None = None
def _load_text(path: Path) -> str:
_is_supported_file_path(path)
return path.read_text(encoding="utf-8-sig")
def load_benchmark_dataset(path: Path) -> dict[str, Any]:
payload = json.loads(_load_text(path))
if not isinstance(payload, dict):
raise ValueError("Benchmark fixturebestand moet een JSON-object bevatten.")
return payload
def _parser_case_to_report(case: dict[str, Any], result) -> dict[str, Any]:
jobs = result.jobs
expected_count = int(case.get("expected_job_count", 1))
expected_fields = list(case.get("expected_fields", DEFAULT_PARSER_FIELDS))
sample_fields = []
for field in expected_fields:
sample_fields.append(any(_value_present(getattr(job, field, None)) for job in jobs))
fields_ok = sum(1 for item in sample_fields if item)
expected_parser_key = case.get("expected_parser_key")
expected_parser_version = case.get("expected_parser_version")
parser_key_match = (
result.parser_key == expected_parser_key if expected_parser_key is not None else True
)
parser_version_match = (
result.parser_version == expected_parser_version
if expected_parser_version is not None
else True
)
passed = (
parser_key_match
and parser_version_match
and len(jobs) >= expected_count
and all(sample_fields)
)
return {
"id": case.get("id"),
"fixture": case.get("fixture"),
"parser_key": result.parser_key,
"parser_version": result.parser_version,
"expected_parser_key": expected_parser_key,
"expected_parser_version": expected_parser_version,
"extracted": len(jobs),
"expected_job_count": expected_count,
"covered_fields": fields_ok,
"expected_fields": len(expected_fields),
"coverage_ratio": round(fields_ok / len(expected_fields), 3) if expected_fields else 1.0,
"warnings": result.warnings,
"passed": passed,
}
def run_parser_benchmark(case_definitions: list[dict[str, Any]]) -> dict[str, Any]:
cases: list[dict[str, Any]] = []
total_fields = 0
covered_fields = 0
for case in case_definitions:
fixture_path = Path(case["fixture"])
content = _load_text(fixture_path)
document = RawDocument(
source=case.get("_source", None),
source_run=case.get("_source_run", None),
url=case["url"],
final_url=case["url"],
kind=case.get("kind", RawDocument.Kind.HTML),
content_type=case.get("content_type", "text/html"),
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
body_text=content,
byte_length=len(content.encode("utf-8")),
retain_until=timezone.now(),
)
result = registry.extract(document)
report = _parser_case_to_report(case, result)
total_fields += report["expected_fields"]
covered_fields += report["covered_fields"]
cases.append(report)
coverage_ratio = round(covered_fields / total_fields, 3) if total_fields else 0.0
return {
"status": "passed" if coverage_ratio >= PARSER_EXPECTED_MIN_COVERAGE else "failed",
"coverage_ratio": coverage_ratio,
"unknown_data_ratio": round(1.0 - coverage_ratio, 3),
"covered_fields": covered_fields,
"expected_fields": total_fields,
"cases": cases,
}
def _build_draft(case: dict[str, Any]) -> CanonicalJobDraft:
title = case.get("title", "Vacature")
normalized_title = case.get("normalized_title") or normalize_token(title)
employer_name = case.get("employer_name", "Onbekende werkgever")
location_text = case.get("location_text", "")
description = case.get("description_text", "")
payload = (title + normalized_title + employer_name + location_text + description).encode(
"utf-8"
)
return CanonicalJobDraft(
source_url=case.get("source_url", "https://benchmark.vr.local/job"),
canonical_url=case.get("canonical_url", "https://benchmark.vr.local/job"),
external_id=case.get("external_id", ""),
title=title,
normalized_title=normalized_title,
job_family=case.get("job_family", ""),
employer_name=employer_name,
employer_domain=case.get("employer_domain", "vr.example.org"),
location_text=location_text,
region=case.get("region", ""),
municipality=case.get("municipality", location_text.split(",")[0] if location_text else ""),
postal_code=case.get("postal_code", ""),
country=case.get("country", "BE"),
workplace_type=case.get("workplace_type", ""),
employment_types=case.get("employment_types", []),
language=case.get("language", "nl"),
description_html=description,
description_text=description,
date_posted=None,
valid_through=None,
compensation={},
skills_required=case.get("skills_required", []),
skills_preferred=case.get("skills_preferred", []),
content_hash=hashlib.sha256(payload).hexdigest(),
canonical_key=case.get(
"canonical_key",
hashlib.sha256(
(case.get("canonical_url", "benchmark") + title).encode("utf-8")
).hexdigest(),
),
)
def _seed_job_payload(seed: dict[str, Any]) -> tuple[dict[str, Any], dict[str, str]]:
title = seed["title"]
normalized_title = seed.get("normalized_title") or normalize_token(title)
employer_name = seed["employer_name"]
employer_domain = seed.get("employer_domain", "vr.example.org")
normalized_name = seed.get("employer_normalized_name") or normalize_token(employer_name)
location_text = seed.get("location_text", "")
payload = {
"employer": {
"name": employer_name,
"normalized_name": normalized_name,
"domain": employer_domain,
},
"job": {
"original_title": title,
"normalized_title": normalized_title,
"job_family": seed.get("job_family", "it"),
"canonical_url": seed["canonical_url"],
"canonical_key": seed.get(
"canonical_key",
hashlib.sha256(seed["canonical_url"].encode("utf-8")).hexdigest(),
),
"content_hash": hashlib.sha256(
(seed["canonical_url"] + title).encode("utf-8")
).hexdigest(),
"description_text": seed.get("description_text", ""),
"raw_location": location_text,
"region": seed.get("region", ""),
"municipality": seed.get(
"municipality", location_text.split(",")[0] if location_text else ""
),
"postal_code": seed.get("postal_code", ""),
"country": seed.get("country", "BE"),
"workplace_type": seed.get("workplace_type", JobPosting.Workplace.UNKNOWN),
"employment_types": seed.get("employment_types", []),
"requirements": seed.get("requirements", []),
"benefits": seed.get("benefits", []),
"status": JobPosting.Status.ACTIVE,
},
"alias": {
"external_id": seed.get("external_id", ""),
"url": seed["canonical_url"],
},
}
return payload, {"employer_name": employer_name, "employer_domain": employer_domain}
def _create_seed_jobs(
seeds: list[dict[str, Any]],
source: Source,
artifacts: BenchmarkArtifact,
) -> tuple[list[JobPosting], dict[str, JobPosting]]:
id_index = {}
seed_jobs: list[JobPosting] = []
for seed in seeds:
payload, employer_info = _seed_job_payload(seed)
employer, _ = Employer.objects.get_or_create(
normalized_name=employer_info["employer_name"],
defaults={
"name": employer_info["employer_name"],
"domain": employer_info["employer_domain"],
"is_direct_employer": True,
"is_recruiter": False,
"confidence": Decimal("0.95"),
},
)
job = JobPosting.objects.create(
employer=employer,
description_html_sanitized=payload["job"]["description_text"],
extraction_confidence=Decimal("0.95"),
analysis_features={},
direct_employer=True,
recruiter=False,
**payload["job"],
)
alias = JobSourceAlias.objects.create(
job=job,
source=source,
url=seed["canonical_url"],
canonical_url=seed["canonical_url"],
external_id=payload["alias"]["external_id"],
source_title=seed["title"],
source_employer=seed["employer_name"],
is_canonical=True,
extraction_method="benchmark-seed",
extraction_confidence=Decimal("0.95"),
)
seed_jobs.append(job)
if artifacts.job_ids is not None:
artifacts.job_ids.append(job.id)
if artifacts.alias_ids is not None:
artifacts.alias_ids.append(alias.id)
id_index[seed["id"]] = job
return seed_jobs, id_index
def run_dedupe_benchmark(
source: Source,
seed_cases: list[dict[str, Any]],
query_cases: list[dict[str, Any]],
*,
artifacts: BenchmarkArtifact,
) -> dict[str, Any]:
seed_jobs, seed_map = _create_seed_jobs(seed_cases, source, artifacts)
true_positive = 0
false_positive = 0
false_negative = 0
true_negative = 0
case_reports = []
for case in query_cases:
expected_match = case.get("expected_match")
threshold = float(case.get("threshold", 0.92))
draft = _build_draft(case["query"])
decision = find_existing_job(draft, threshold=threshold, source=source)
expected_job = seed_map.get(expected_match) if expected_match else None
matched = decision.job is not None
expected_match_hit = expected_job is not None
expected_match_correct = expected_job is not None and decision.job == expected_job
if expected_match_hit and matched:
if expected_match_correct:
true_positive += 1
else:
false_positive += 1
false_negative += 1
elif expected_match_hit and not matched:
false_negative += 1
elif expected_match_hit is False and matched:
false_positive += 1
else:
true_negative += 1
case_reports.append(
{
"id": case.get("id"),
"expected_match": expected_match,
"expected_match_present": bool(expected_match_hit),
"decision": decision.reason,
"decision_similarity": decision.similarity,
"matched": matched,
"matched_job_id": str(decision.job.pk) if decision.job else None,
"is_expected_match": bool(expected_match_hit and expected_match_correct),
"query": case.get("id"),
}
)
precision = (
round(true_positive / (true_positive + false_positive), 3)
if (true_positive + false_positive)
else 1.0
)
recall = (
round(true_positive / (true_positive + false_negative), 3)
if (true_positive + false_negative)
else 1.0
)
return {
"status": "passed"
if precision >= DEDUPE_EXPECTED_MIN_PRECISION and recall >= DEDUPE_EXPECTED_MIN_RECALL
else "failed",
"precision": precision,
"recall": recall,
"false_merges": false_positive,
"missed_merges": false_negative,
"true_positive": true_positive,
"true_negative": true_negative,
"false_positive": false_positive,
"false_negative": false_negative,
"case_reports": case_reports,
"seed_jobs": len(seed_jobs),
}
def _build_ranking_jobs(
profile: SearchProfile,
source: Source,
count: int,
base_url: str,
artifacts: BenchmarkArtifact,
) -> list[JobPosting]:
titles = [
"Infrastructure Engineer",
"Network Engineer",
"Security Specialist",
"Cloud Architect",
"Sysadmin",
]
employers = ["Example IT", "Example Cloud", "Example Secure"]
locations = ["Hasselt, Limburg", "Aalst, Oost-Vlaanderen", "Genk, Limburg", "Brussel"]
jobs = []
for index in range(count):
title = titles[index % len(titles)]
employer_name = employers[index % len(employers)]
normalized_title = normalize_token(title)
canonical = f"{base_url}/bench/job/{index:06d}"
description = (
f"{title} op projectniveau met ervaring in Microsoft 365." if index % 4 else ""
)
employer, _ = Employer.objects.get_or_create(
normalized_name=normalize_token(employer_name),
defaults={
"name": employer_name,
"domain": f"{normalize_token(employer_name).replace(' ', '-')}.example.org",
"is_direct_employer": True,
"is_recruiter": False,
"confidence": Decimal("0.95"),
},
)
jobs.append(
JobPosting(
employer=employer,
original_title=title,
normalized_title=normalized_title,
job_family=normalized_title.split(" ")[0] or "it",
canonical_url=canonical,
canonical_key=hashlib.sha256(canonical.encode("utf-8")).hexdigest(),
content_hash=hashlib.sha256((canonical + description).encode("utf-8")).hexdigest(),
description_html_sanitized=description,
description_text=description,
raw_location=locations[index % len(locations)],
region="Limburg" if "Limburg" in locations[index % len(locations)] else "Brabant",
municipality=(locations[index % len(locations)].split(",")[0]).strip(),
postal_code="3500" if index % 2 == 0 else "8500",
country="BE",
workplace_type=JobPosting.Workplace.HYBRID
if index % 3 == 0
else JobPosting.Workplace.REMOTE,
employment_types=["full_time"] if index % 2 == 0 else ["permanent"],
extraction_confidence=Decimal("0.95"),
analysis_features={
"support_ratio": 0.01 * (index % 6),
"public_sector_signal": 0.0,
"experience_years_max": 3 + (index % 4),
},
)
)
created_jobs = JobPosting.objects.bulk_create(jobs)
for job in created_jobs:
alias = JobSourceAlias.objects.create(
job=job,
source=source,
url=job.canonical_url,
canonical_url=job.canonical_url,
external_id="",
source_title=job.original_title,
source_employer=job.employer_name,
extraction_method="benchmark",
extraction_confidence=Decimal("0.95"),
is_canonical=True,
)
if artifacts.job_ids is not None:
artifacts.job_ids.append(job.id)
if artifacts.alias_ids is not None:
artifacts.alias_ids.append(alias.id)
return list(created_jobs)
def _score_top_n_jobs(profile: SearchProfile, jobs: list[JobPosting], top_n: int) -> list[str]:
scores: list[tuple[float, int, str]] = []
for job in jobs:
result = calculate_score(job, profile)
scores.append((float(result.score), job.pk, str(job.pk)))
return [
job_id for _, _, job_id in sorted(scores, key=lambda item: item[0], reverse=True)[:top_n]
]
def run_ranking_benchmark(
profile: SearchProfile,
source: Source,
top_n: int,
artifacts: BenchmarkArtifact,
) -> dict[str, Any]:
ranking_jobs = _build_ranking_jobs(
profile, source, PERF_RANKING_JOBS, "https://bench.example.org/rank", artifacts
)
base_weights = dict(profile.weights)
pre_weights = _score_top_n_jobs(profile, ranking_jobs, top_n)
profile.weights = {**base_weights, "content": 30.0, "skills": 35.0}
profile.save(update_fields=["weights", "updated_at"])
post_weights = _score_top_n_jobs(profile, ranking_jobs, top_n)
profile.weights = base_weights
profile.save(update_fields=["weights", "updated_at"])
pre_set = set(pre_weights)
post_set = set(post_weights)
churn = round(1 - (len(pre_set.intersection(post_set)) / top_n), 3) if top_n else 0.0
unknown_fields = {
"description_text": 0,
"raw_location": 0,
"employment_types": 0,
"analysis_features": 0,
}
unknown_total = 0
for job in ranking_jobs:
is_unknown = 0
if not job.description_text:
unknown_fields["description_text"] += 1
is_unknown += 1
if not job.raw_location:
unknown_fields["raw_location"] += 1
is_unknown += 1
if not job.employment_types:
unknown_fields["employment_types"] += 1
is_unknown += 1
if not job.analysis_features:
unknown_fields["analysis_features"] += 1
is_unknown += 1
unknown_total += int(is_unknown > 0)
unknown_ratio = round(unknown_total / len(ranking_jobs), 3) if ranking_jobs else 0.0
return {
"status": "passed" if churn <= TOP_N_EXPECTED_MAX_CHURN else "failed",
"top_n": top_n,
"pre_top_n": pre_weights,
"post_top_n": post_weights,
"churn_ratio": churn,
"unknown_data_ratio": unknown_ratio,
"unknown_fields": unknown_fields,
"seed_jobs_count": len(ranking_jobs),
}
def _clean_benchmark(artifacts: BenchmarkArtifact) -> None:
if artifacts.scorerun_ids:
ScoreRun.objects.filter(pk__in=artifacts.scorerun_ids).delete()
if artifacts.alias_ids:
JobSourceAlias.objects.filter(pk__in=artifacts.alias_ids).delete()
if artifacts.raw_ids:
RawDocument.objects.filter(pk__in=artifacts.raw_ids).delete()
if artifacts.job_ids:
JobPosting.objects.filter(pk__in=artifacts.job_ids).delete()
if artifacts.source_run_id:
SourceRun.objects.filter(pk=artifacts.source_run_id).delete()
if artifacts.source_id:
Source.objects.filter(pk=artifacts.source_id).delete()
if artifacts.profile_id:
SearchProfile.objects.filter(pk=artifacts.profile_id).delete()
if artifacts.user_id:
get_user_model().objects.filter(pk=artifacts.user_id).delete()
class QueryCounter:
"""Count database executions without Django's bounded debug-query log."""
def __init__(self) -> None:
self.count = 0
self.types: dict[str, int] = {}
def __call__(self, execute: Any, sql: str, params: Any, many: bool, context: Any) -> Any:
self.count += 1
query_type = sql.lstrip().split(None, 1)[0].upper() if sql.strip() else "UNKNOWN"
self.types[query_type] = self.types.get(query_type, 0) + 1
return execute(sql, params, many, context)
@contextmanager
def _query_capture() -> Any:
counter = QueryCounter()
with connection.execute_wrapper(counter):
yield counter
def run_performance_benchmark(
profile: SearchProfile,
source: Source,
*,
jobs_to_process: int,
top_n: int,
run_import: bool = True,
) -> dict[str, Any]:
label = f"vr116-{timezone.now().strftime('%Y%m%d%H%M%S')}"
base_url = "https://bench.example.org"
source_run = SourceRun.objects.create(source=source)
template = _load_text(Path("fixtures/pages/sample_generic_job.html"))
candidates: list[JobPosting] = []
ranking_sample: list[int] = []
import_query_count = 0
rescore_query_count = 0
list_query_count = 0
detail_query_count = 0
current_bytes = 0
peak_bytes = 0
current_ms_total = 0.0
timing_stats: dict[str, float] = {
"p50_ms": 0.0,
"p95_ms": 0.0,
"import_p50_ms": 0.0,
"import_p95_ms": 0.0,
"rescore_p50_ms": 0.0,
"rescore_p95_ms": 0.0,
"list_p95_ms": 0.0,
"detail_p95_ms": 0.0,
}
tracemalloc.start()
import_timings: list[float] = []
persistence_context = PersistenceContext()
rescore_timings: list[float] = []
list_timings: list[float] = []
detail_timings: list[float] = []
query_count_total = 0
try:
with _query_capture() as captured_import:
if run_import:
for index in range(jobs_to_process):
unique_url = f"{base_url}/bench/{label}/{index}"
body = template.replace(
"https://careers.example.net/jobs/workplace-engineer", unique_url
)
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
document = RawDocument.objects.create(
source=source,
source_run=source_run,
url=unique_url,
final_url=unique_url,
kind=RawDocument.Kind.HTML,
content_type="text/html",
content_hash=body_hash,
body_text=body,
byte_length=len(body.encode("utf-8")),
retain_until=timezone.now() + timezone.timedelta(days=7),
)
start = perf_counter()
process_raw_document(document, context=persistence_context)
import_timings.append((perf_counter() - start) * 1000)
import_query_count = captured_import.count
import_query_types = captured_import.types
query_count_total += import_query_count
candidates = list(
JobPosting.objects.filter(
canonical_url__startswith=f"{base_url}/bench/{label}/",
status=JobPosting.Status.ACTIVE,
).select_related("employer")
)
if not candidates:
raise RuntimeError("Benchmarkimport produceerde geen vacatures.")
with _query_capture() as captured_rescore:
for start in range(0, len(candidates), 250):
batch = candidates[start : start + 250]
batch_start = perf_counter()
rescore_jobs_with_profiles(batch, profile_id=profile.id)
rescore_timings.append((perf_counter() - batch_start) * 1000)
rescore_query_count = captured_rescore.count
rescore_query_types = captured_rescore.types
query_count_total += rescore_query_count
with _query_capture() as captured_list:
ranking_sample = list(
JobPosting.objects.filter(source_aliases__source=source)
.order_by("-created_at")
.values_list("id", flat=True)[:top_n]
)
for job_id in ranking_sample:
start = perf_counter()
list(
JobPosting.objects.filter(id=job_id)
.select_related("employer")
.prefetch_related("source_aliases", "scores")
)
list_timings.append((perf_counter() - start) * 1000)
with _query_capture() as captured_detail:
for job_id in ranking_sample:
start = perf_counter()
list(
ScoreRun.objects.filter(
job_id=job_id,
profile=profile,
).order_by("-created_at")
)
detail_timings.append((perf_counter() - start) * 1000)
list_query_count = captured_list.count
detail_query_count = captured_detail.count
list_query_types = captured_list.types
detail_query_types = captured_detail.types
query_count_total += list_query_count + detail_query_count
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
if query_count_total == 0:
query_count_total = (
len(import_timings) + len(rescore_timings) + len(list_timings) + len(detail_timings)
)
current_ms_total = (
sum(import_timings) + sum(rescore_timings) + sum(list_timings) + sum(detail_timings)
)
operation_times = import_timings + rescore_timings + list_timings + detail_timings
timing_stats = {
"p50_ms": round(_percentile(operation_times, 50), 3),
"p95_ms": round(_percentile(operation_times, 95), 3),
"import_p50_ms": round(_percentile(import_timings, 50), 3),
"import_p95_ms": round(_percentile(import_timings, 95), 3),
"rescore_p50_ms": round(_percentile(rescore_timings, 50), 3),
"rescore_p95_ms": round(_percentile(rescore_timings, 95), 3),
"list_p95_ms": round(_percentile(list_timings, 95), 3),
"detail_p95_ms": round(_percentile(detail_timings, 95), 3),
"import_total_s": round(sum(import_timings) / 1000, 3),
"rescore_total_s": round(sum(rescore_timings) / 1000, 3),
"list_total_s": round(sum(list_timings) / 1000, 3),
"detail_total_s": round(sum(detail_timings) / 1000, 3),
}
finally:
current_bytes, peak_bytes = tracemalloc.get_traced_memory()
tracemalloc.stop()
if query_count_total == 0:
query_count_total = (
len(import_timings) + len(rescore_timings) + len(list_timings) + len(detail_timings)
)
return {
"jobs": len(candidates),
"top_n": top_n,
"query_count": query_count_total,
"source_run_id": source_run.pk,
"import": {
"duration_ms_total": round(sum(import_timings), 3),
"p50_ms": timing_stats["import_p50_ms"],
"p95_ms": timing_stats["import_p95_ms"],
"query_count": import_query_count,
"query_types": import_query_types,
"timing_count": len(import_timings),
},
"rescore": {
"duration_ms_total": round(sum(rescore_timings), 3),
"p50_ms": timing_stats["rescore_p50_ms"],
"p95_ms": timing_stats["rescore_p95_ms"],
"query_count": rescore_query_count,
"query_types": rescore_query_types,
"timing_count": len(rescore_timings),
},
"dashboard": {
"list_query_count": list_query_count,
"detail_query_count": detail_query_count,
"list_query_types": list_query_types,
"detail_query_types": detail_query_types,
"list_p95_ms": timing_stats["list_p95_ms"],
"detail_p95_ms": timing_stats["detail_p95_ms"],
"timing_count": len(list_timings) + len(detail_timings),
},
"performance": {
"p50_ms": timing_stats["p50_ms"],
"p95_ms": timing_stats["p95_ms"],
"memory_peak_mb": round(peak_bytes / 1024 / 1024, 3),
"memory_current_mb": round(current_bytes / 1024 / 1024, 3),
"nfr_007": _to_bool(timing_stats["p95_ms"] <= NFR_007_P95_MS_LIMIT),
"total_duration_ms": round(current_ms_total, 3),
},
"avg_scores_import_ms": round(mean(import_timings), 3) if import_timings else 0.0,
"avg_scores_rescore_ms": round(mean(rescore_timings), 3) if rescore_timings else 0.0,
"avg_scores_list_ms": round(mean(list_timings), 3) if list_timings else 0.0,
"avg_scores_detail_ms": round(mean(detail_timings), 3) if detail_timings else 0.0,
}
def run_benchmark_report(
dataset_path: Path,
*,
quick: bool = False,
top_n: int = DEFAULT_TOP_N,
jobs: int | None = None,
skip_performance: bool = False,
) -> dict[str, Any]:
dataset = load_benchmark_dataset(dataset_path)
if jobs is None:
jobs = QUICK_JOBS if quick else FULL_JOBS
if top_n <= 0:
raise ValueError("top_n moet groter zijn dan 0.")
artifacts = BenchmarkArtifact(job_ids=[], raw_ids=[], alias_ids=[], scorerun_ids=[])
parser_cases = dataset["parser_coverage"]
parser_report = run_parser_benchmark(parser_cases)
dedupe_definition = dataset.get("dedupe", {})
seed_cases = dedupe_definition.get("seed_jobs", [])
query_cases = dedupe_definition.get("query_cases", [])
dedupe_report: dict[str, Any] = {"status": "skipped"}
ranking_report: dict[str, Any] = {"status": "skipped"}
performance_report: dict[str, Any] = {"status": "skipped"}
nfr_007 = {"measured": False, "passed": False, "p95_ms": None}
try:
User = get_user_model()
profile_user = User.objects.create_user(
username=f"benchmark_{timezone.now().strftime('%Y%m%d%H%M%S')}",
email="benchmark@example.invalid",
password="benchmark-password",
)
profile = SearchProfile.objects.create(
user=profile_user,
name="VR-116 benchmark profile",
is_active=True,
home_municipality="Hasselt",
home_latitude=Decimal("50.9325"),
home_longitude=Decimal("5.3396"),
max_distance_km=75,
desired_titles=["infrastructure engineer", "network engineer", "security specialist"],
desired_skills=["microsoft 365", "cloud", "vmware", "remote support"],
allowed_employment_types=["full_time", "permanent", "remote", "hybrid"],
preferred_workplace=["hybrid", "remote"],
hard_rules={"excluded_title_terms": []},
weights={
"content": 28.0,
"skills": 22.0,
"location": 20.0,
"conditions": 10.0,
"employer": 8.0,
"seniority": 6.0,
"preferences": 6.0,
},
recommendation_threshold=65,
top_match_threshold=90,
)
artifacts.profile_id = profile.pk
artifacts.user_id = profile_user.pk
source = Source.objects.create(
name=f"VR-116 benchmark source {timezone.now().strftime('%H%M%S')}",
source_type=Source.Type.EMPLOYER,
base_url="https://bench.example.org",
domain="bench.example.org",
status=Source.Status.ACTIVE,
policy=Source.Policy.ALLOW,
parser_key="auto",
)
artifacts.source_id = source.pk
if seed_cases and query_cases:
dedupe_report = run_dedupe_benchmark(
source=source,
seed_cases=seed_cases,
query_cases=query_cases,
artifacts=artifacts,
)
ranking_report = run_ranking_benchmark(
profile=profile,
source=source,
top_n=top_n,
artifacts=artifacts,
)
if not skip_performance:
performance_report = run_performance_benchmark(
profile,
source,
jobs_to_process=jobs,
top_n=top_n,
)
if jobs >= FULL_JOBS and not quick:
nfr_007 = {
"measured": True,
"passed": performance_report["performance"]["nfr_007"],
"p95_ms": performance_report["performance"]["p95_ms"],
}
finally:
_clean_benchmark(artifacts)
passed = (
parser_report["status"] == "passed"
and dedupe_report.get("status", "skipped") in {"passed", "skipped"}
and ranking_report.get("status", "skipped") in {"passed", "skipped"}
and (
performance_report.get("status", "skipped") == "skipped"
or performance_report["performance"]["p95_ms"] <= NFR_007_P95_MS_LIMIT
)
)
return {
"dataset_version": dataset.get("dataset_version", "unknown"),
"timestamp_utc": timezone.now().isoformat(),
"parser": parser_report,
"dedupe": dedupe_report,
"ranking": ranking_report,
"performance": performance_report,
"nfr_007": nfr_007,
"top_n": top_n,
"jobs_processed": jobs,
"hardware": {
"platform": platform.platform(),
"python": platform.python_version(),
"cpu_count": os.cpu_count(),
"release": platform.release(),
"debug_mode": settings.DEBUG,
},
"status": "passed" if passed else "failed",
}
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Kwaliteits- en performance-benchmarks voor VR-116"
)
parser.add_argument(
"--dataset",
default=str(DEFAULT_DATASET),
help="Pad naar fixturebestand (standaard: fixtures/benchmark/quality_benchmark.json).",
)
parser.add_argument("--quick", action="store_true", help="Kleinere, snelle offline run.")
parser.add_argument(
"--top-n", type=int, default=DEFAULT_TOP_N, help="Top-N grootte voor churnmeting."
)
parser.add_argument(
"--jobs",
type=int,
default=None,
help="Aantal vacatures voor performance-import; default 250 (quick) of 25000.",
)
parser.add_argument("--skip-performance", action="store_true", help="Sla performancerun over.")
parser.add_argument("--json", action="store_true", help="Toon machineleesbaar JSON.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
report = run_benchmark_report(
Path(args.dataset),
quick=args.quick,
top_n=args.top_n,
jobs=args.jobs,
skip_performance=args.skip_performance,
)
if args.json:
print(json.dumps(report, indent=2, sort_keys=True))
else:
parser = report["parser"]
dedupe = report["dedupe"]
ranking = report["ranking"]
print(
f"Parser dekking: {parser['coverage_ratio']} (onzeker: {parser['unknown_data_ratio']})"
)
print(
f"Dedupe precision: {dedupe.get('precision', 0.0)} recall: {dedupe.get('recall', 0.0)}"
)
print(f"Top-{report['top_n']} churn: {ranking.get('churn_ratio', 0.0)}")
print(f"NFR-007: {report['nfr_007']}")
print(f"Overall status: {report['status']}")
return 0 if report["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from statistics import mean
from typing import Any
from urllib.parse import urlparse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
import django
django.setup()
from django.conf import settings # noqa: E402
from django.core.management import call_command # noqa: E402
from scripts.benchmark import DEFAULT_DATASET, run_benchmark_report # noqa: E402
SAFE_DATABASE_PREFIX = "vacatureradar_bench_"
SAFE_PUBLIC_HOSTS = {"", "benchmark.invalid", "localhost", "127.0.0.1"}
class UnsafeBenchmarkEnvironment(RuntimeError):
pass
def validate_benchmark_identity(database: dict[str, Any], public_base_url: str) -> None:
engine = str(database.get("ENGINE", ""))
name = str(database.get("NAME", ""))
host = str(database.get("HOST", ""))
public_host = (urlparse(public_base_url).hostname or "").casefold()
if engine != "django.db.backends.postgresql":
raise UnsafeBenchmarkEnvironment("De importbenchmark vereist PostgreSQL.")
if not name.startswith(SAFE_DATABASE_PREFIX):
raise UnsafeBenchmarkEnvironment(
f"Onveilige databasenaam {name!r}; verwacht prefix {SAFE_DATABASE_PREFIX!r}."
)
if host not in {"127.0.0.1", "localhost", "postgres"}:
raise UnsafeBenchmarkEnvironment(f"Onveilige benchmarkdatabasehost {host!r}.")
if public_host not in SAFE_PUBLIC_HOSTS:
raise UnsafeBenchmarkEnvironment(
f"Publieke host {public_host!r} is niet toegestaan voor een benchmark."
)
def _summary(reports: list[dict[str, Any]]) -> dict[str, float]:
performance = [report["performance"] for report in reports]
imports = [item["import"] for item in performance]
return {
"query_count_mean": round(mean(item["query_count"] for item in imports), 3),
"query_count_total_mean": round(mean(item["query_count"] for item in performance), 3),
"duration_ms_mean": round(mean(item["duration_ms_total"] for item in imports), 3),
"p50_ms_mean": round(mean(item["p50_ms"] for item in imports), 3),
"p95_ms_mean": round(mean(item["p95_ms"] for item in imports), 3),
"throughput_per_second": round(
mean(
report["jobs_processed"] / (item["duration_ms_total"] / 1000)
for report, item in zip(reports, imports, strict=True)
),
3,
),
"memory_peak_mb_mean": round(
mean(item["performance"]["memory_peak_mb"] for item in performance), 3
),
}
def run_isolated_benchmark(
*, dataset: Path, jobs: int, iterations: int, output: Path
) -> dict[str, Any]:
validate_benchmark_identity(settings.DATABASES["default"], settings.PUBLIC_BASE_URL)
if iterations < 1:
raise ValueError("iterations moet minstens 1 zijn.")
call_command("migrate", interactive=False, verbosity=0)
call_command("flush", interactive=False, verbosity=0)
warmup = run_benchmark_report(dataset, quick=True, jobs=jobs)
measured: list[dict[str, Any]] = []
for _ in range(iterations):
call_command("flush", interactive=False, verbosity=0)
measured.append(run_benchmark_report(dataset, quick=True, jobs=jobs))
report = {
"schema_version": "1.0",
"database_identity": {
"engine": settings.DATABASES["default"]["ENGINE"],
"name_prefix": SAFE_DATABASE_PREFIX,
"host": settings.DATABASES["default"]["HOST"],
},
"configuration": {
"jobs": jobs,
"iterations": iterations,
"warmup_runs": 1,
"concurrency": 1,
"dataset": str(dataset),
},
"warmup_status": warmup["status"],
"summary": _summary(measured),
"iterations": measured,
"status": "passed" if all(item["status"] == "passed" for item in measured) else "failed",
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return report
def main() -> int:
parser = argparse.ArgumentParser(description="Fail-closed geïsoleerde importbenchmark")
parser.add_argument("--dataset", type=Path, default=DEFAULT_DATASET)
parser.add_argument("--jobs", type=int, default=250)
parser.add_argument("--iterations", type=int, default=3)
parser.add_argument(
"--output", type=Path, default=Path("artifacts/import-performance-report.json")
)
args = parser.parse_args()
report = run_isolated_benchmark(
dataset=args.dataset, jobs=args.jobs, iterations=args.iterations, output=args.output
)
print(json.dumps(report, indent=2, sort_keys=True))
return 0 if report["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p local media logs backups
if [[ ! -f .env ]]; then
cat > .env <<'ENV'
DJANGO_SECRET_KEY=codex-dev-only-change-before-production-6e9fb6372d3e43cc9b74
DJANGO_DEBUG=1
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8080
DJANGO_TIME_ZONE=Europe/Brussels
DATABASE_URL=
REDIS_URL=redis://localhost:6379/0
CELERY_TASK_ALWAYS_EAGER=1
VACATURERADAR_ADMIN_USERNAME=admin
VACATURERADAR_ADMIN_PASSWORD=codex-local-only
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
ENV
chmod 600 .env
echo "Lokale ontwikkel-.env aangemaakt (niet voor productie)."
fi
set -a
# shellcheck disable=SC1091
source .env
set +a
uv sync --all-groups
uv run python manage.py migrate --noinput
uv run python manage.py bootstrap_instance --with-demo
./scripts/codex_verify.sh
cat <<'OUT'
Bootstrap voltooid.
Lokale login: admin / codex-local-only (alleen wanneer het script zelf .env heeft aangemaakt)
Start: uv run python manage.py runserver 0.0.0.0:8080
Lees daarna docs/ai/PROJECT_STATE.md en werk docs/ai/BACKLOG.yaml van boven naar beneden af.
OUT
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p local
if command -v uv >/dev/null 2>&1; then
PYTHON=(uv run python)
RUFF=(uv run ruff)
PYTEST=(uv run pytest)
elif [[ -x .venv/Scripts/python.exe ]]; then
PYTHON=(.venv/Scripts/python.exe)
RUFF=(.venv/Scripts/python.exe -m ruff)
PYTEST=(.venv/Scripts/python.exe -m pytest)
elif [[ -x .venv/bin/python ]]; then
PYTHON=(.venv/bin/python)
RUFF=(.venv/bin/python -m ruff)
PYTEST=(.venv/bin/python -m pytest)
else
printf 'Geen uv of project-venv gevonden.\n' >&2
exit 1
fi
"${RUFF[@]}" check .
"${PYTHON[@]}" manage.py check
"${PYTHON[@]}" manage.py makemigrations --check --dry-run
"${PYTEST[@]}" --cov=apps --cov=config --cov-report=term-missing
"${PYTHON[@]}" scripts/validate_task_ledger.py
"${PYTHON[@]}" scripts/validate_repository.py
printf '\nAlle kwaliteitsgates geslaagd.\n'
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import os
import tempfile
from pathlib import Path
from urllib.parse import urlparse
class ConfigurationError(ValueError):
pass
def normalize_public_url(value: str) -> tuple[str, str]:
raw = value.strip().rstrip("/")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ConfigurationError(
"Gebruik een volledige http(s)-URL, bijvoorbeeld https://jobs.example.be"
)
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ConfigurationError(
"De publieke URL mag geen credentials, query of fragment bevatten."
)
if parsed.path not in {"", "/"}:
raise ConfigurationError(
"De applicatie moet op de root van het domein gepubliceerd worden."
)
try:
port = parsed.port
except ValueError as exc:
raise ConfigurationError("De publieke URL bevat een ongeldige poort.") from exc
host = parsed.hostname.casefold()
display_host = f"[{host}]" if ":" in host else host
default_port = 443 if parsed.scheme == "https" else 80
port_suffix = f":{port}" if port and port != default_port else ""
return f"{parsed.scheme}://{display_host}{port_suffix}", host
def parse_env(text: str) -> tuple[list[str], dict[str, str]]:
lines = text.splitlines()
values: dict[str, str] = {}
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return lines, values
def merge_csv(existing: str, additions: list[str]) -> str:
result: list[str] = []
seen: set[str] = set()
for item in [*existing.split(","), *additions]:
value = item.strip()
if value and value.casefold() not in seen:
seen.add(value.casefold())
result.append(value)
return ",".join(result)
def render_env(lines: list[str], updates: dict[str, str]) -> str:
rendered: list[str] = []
written: set[str] = set()
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in updates:
rendered.append(f"{key}={updates[key]}")
written.add(key)
continue
rendered.append(line)
missing = [key for key in updates if key not in written]
if missing:
if rendered and rendered[-1].strip():
rendered.append("")
rendered.append("# Publieke URL en reverse-proxyhardening")
rendered.extend(f"{key}={updates[key]}" for key in missing)
return "\n".join(rendered).rstrip() + "\n"
def build_updates(
current: dict[str, str],
public_url: str,
*,
hsts_seconds: int,
cache_url: str | None,
trusted_proxy_cidrs: list[str],
) -> dict[str, str]:
origin, host = normalize_public_url(public_url)
if not origin.startswith("https://"):
raise ConfigurationError("Een publieke productie-URL moet HTTPS gebruiken.")
updates = {
"PUBLIC_BASE_URL": origin,
"DJANGO_DEBUG": "0",
"DJANGO_ALLOWED_HOSTS": merge_csv(
current.get("DJANGO_ALLOWED_HOSTS", ""), [host, "127.0.0.1", "localhost"]
),
"DJANGO_CSRF_TRUSTED_ORIGINS": merge_csv(
current.get("DJANGO_CSRF_TRUSTED_ORIGINS", ""), [origin]
),
"SESSION_COOKIE_SECURE": "1",
"CSRF_COOKIE_SECURE": "1",
"SECURE_SSL_REDIRECT": "1",
"SECURE_HSTS_SECONDS": str(hsts_seconds),
"TRUST_PROXY_HEADERS": "1",
"DEMO_READ_ONLY": "1",
"SEARCH_ENGINE_INDEXING_ENABLED": "0",
"HEALTHCHECK_REQUIRE_CACHE": "1",
}
if cache_url:
updates["CACHE_URL"] = cache_url
if trusted_proxy_cidrs:
updates["TRUSTED_PROXY_CIDRS"] = merge_csv(
current.get("TRUSTED_PROXY_CIDRS", ""), trusted_proxy_cidrs
)
return updates
def configure_env(
env_file: Path,
public_url: str,
*,
hsts_seconds: int = 300,
cache_url: str | None = None,
trusted_proxy_cidrs: list[str] | None = None,
) -> list[str]:
if hsts_seconds < 0:
raise ConfigurationError("HSTS-seconden mogen niet negatief zijn.")
original = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
lines, current = parse_env(original)
updates = build_updates(
current,
public_url,
hsts_seconds=hsts_seconds,
cache_url=cache_url,
trusted_proxy_cidrs=trusted_proxy_cidrs or [],
)
output = render_env(lines, updates)
env_file.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix=f".{env_file.name}.", dir=env_file.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
handle.write(output)
os.chmod(temporary_name, 0o600)
os.replace(temporary_name, env_file)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return list(updates)
def main() -> int:
parser = argparse.ArgumentParser(
description="Configureer VacatureRadar veilig voor een publieke reverse-proxy-URL."
)
parser.add_argument("public_url", help="Bijvoorbeeld https://vacatureradar.example.be")
parser.add_argument("--env-file", type=Path, default=Path(".env"))
parser.add_argument(
"--hsts-seconds",
type=int,
default=300,
help="Start conservatief; verhoog na succesvolle HTTPS-validatie.",
)
parser.add_argument("--cache-url", help="Bij Unraid AIO: redis://127.0.0.1:6379/1")
parser.add_argument(
"--trusted-proxy-cidr",
action="append",
default=[],
help="Optioneel vertrouwd proxy-IP/CIDR voor correcte client-IP-rate-limiting.",
)
args = parser.parse_args()
try:
changed = configure_env(
args.env_file,
args.public_url,
hsts_seconds=args.hsts_seconds,
cache_url=args.cache_url,
trusted_proxy_cidrs=args.trusted_proxy_cidr,
)
except (OSError, ConfigurationError) as exc:
parser.error(str(exc))
print(f"Bijgewerkt: {args.env_file}")
print("Veilig ingestelde sleutels: " + ", ".join(changed))
print("Hermaak de container; een gewone restart laadt gewijzigde environment niet opnieuw.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -eu
mkdir -p /app/media /app/logs /app/local
mkdir -p /tmp/celery
python manage.py check --deploy
if [ "${VACATURERADAR_RUN_MIGRATIONS:-1}" = "1" ]; then
python manage.py migrate --noinput
if [ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" = "1" ]; then
python manage.py bootstrap_instance || true
fi
fi
exec "$@"
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
from collections import Counter
from pathlib import Path
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from apps.profiles.models import SearchProfile # noqa: E402
def _load_profile(profile_id: int | None, username: str | None) -> SearchProfile:
if profile_id:
return SearchProfile.objects.get(pk=profile_id)
if username:
profile = SearchProfile.objects.filter(user__username=username, is_active=True).first()
if profile is None:
raise ValueError(f"Geen actief profiel gevonden voor gebruiker {username}")
return profile
raise ValueError("Kies --profile-id of --user.")
def _collect_feedback_churn(profile: SearchProfile) -> dict:
from apps.jobs.models import Feedback
all_feedback = Feedback.objects.filter(profile=profile).order_by("created_at")
status = Counter()
features = Counter()
sample_count = 0
false_negatives = []
for feedback in all_feedback:
learning = (
feedback.metadata.get("learning") if isinstance(feedback.metadata, dict) else None
)
if not isinstance(learning, dict):
continue
reason_code = learning.get("reason_code", "")
status[learning.get("status", "")] += 1
if feature := learning.get("feature"):
features[feature] += 1
sample_count += 1
if learning.get("status") == "queued" and reason_code.startswith("non_learning_"):
false_negatives.append(
{
"job_id": str(feedback.job_id),
"reason_code": reason_code,
}
)
return {
"samples": dict(status),
"features": dict(features),
"total_feedback": all_feedback.count(),
"sample_count": sample_count,
"queued_non_learning_signals": false_negatives[:25],
}
def _collect_weight_churn(profile: SearchProfile) -> list[dict[str, float]]:
revisions = list(profile.revisions.order_by("version"))
if len(revisions) < 2:
return []
base = revisions[0].snapshot
latest = revisions[-1].snapshot
base_weights = base.get("weights", {})
latest_weights = latest.get("weights", {})
churn = []
for feature in sorted(set(base_weights) | set(latest_weights)):
before = float(base_weights.get(feature, 0.0))
after = float(latest_weights.get(feature, 0.0))
if before == after:
continue
churn.append({"feature": feature, "delta": round(after - before, 3)})
return sorted(churn, key=lambda item: abs(item["delta"]), reverse=True)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--profile-id", type=int, default=None)
parser.add_argument("--user", default=None)
parser.add_argument("--top", type=int, default=5)
args = parser.parse_args(argv)
if not args.profile_id and not args.user:
raise SystemExit("Gebruik --profile-id of --user.")
profile = _load_profile(args.profile_id, args.user)
feedback_report = _collect_feedback_churn(profile)
churn = _collect_weight_churn(profile)
report = {
"profile_id": profile.pk,
"profile_name": profile.name,
"learning_enabled": profile.learning_enabled,
"feedback": feedback_report,
"weight_churn": churn[: args.top],
}
output = Path("feedback_learning_report.txt")
output.write_text(
"\n".join(
[
f"Feedbacklearningrapport voor {profile.name} (id={profile.pk})",
f"Learning actief: {'ja' if profile.learning_enabled else 'nee'}",
f"Feedbacktotalen: {feedback_report['total_feedback']}",
f"Statuscounts: {feedback_report['samples']}",
f"Featurecounts: {feedback_report['features']}",
"Top-churn:",
]
+ [f" - {item['feature']}: {item['delta']:+.3f}" for item in report["weight_churn"]]
+ [f"\nMogelijke non-learn false negatives (max {args.top}):"]
+ [
f" - {item['job_id']} ({item['reason_code']})"
for item in feedback_report["queued_non_learning_signals"][: args.top]
]
)
+ "\n",
encoding="utf-8",
)
print(output.read_text(encoding="utf-8"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env python
from secrets import token_urlsafe
print(token_urlsafe(64))
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8080/health/ready/"
try:
with urllib.request.urlopen(url, timeout=4) as response: # noqa: S310 - vaste interne health-URL
payload = json.load(response)
if response.status != 200 or not payload.get("ok"):
raise SystemExit(1)
except (urllib.error.URLError, ValueError, TimeoutError):
raise SystemExit(1) from None
+60
View File
@@ -0,0 +1,60 @@
"""Keep outbound Celery work paused during a verified cold-data migration.
The operator creates the hold file in the candidate data clone before startup
and removes it only after committing the verified deployment. Normal startups
without a hold file behave unchanged. This does not pause an existing worker.
"""
import os
import stat
import sys
import time
from pathlib import Path
HOLD_FILE = Path("/app/local/.migration-worker-hold")
COMMANDS = {
"worker": [
"/app/.venv/bin/celery",
"-A",
"config",
"worker",
"-l",
"INFO",
"-Q",
"high,default,low",
"--concurrency=2",
],
"scheduler": [
"/app/.venv/bin/celery",
"-A",
"config",
"beat",
"-l",
"INFO",
"--schedule",
"/tmp/celerybeat-schedule", # noqa: S108 - existing supervised container-local schedule path
],
}
def hold_active(path=HOLD_FILE):
try:
mode = path.lstat().st_mode
except FileNotFoundError:
return False
if not stat.S_ISREG(mode):
raise RuntimeError("Migration hold must be a regular file")
return True
def start(role, *, check=hold_active, sleep=time.sleep, execute=os.execv):
if role not in COMMANDS:
raise ValueError("Unsupported supervised process")
while check():
sleep(1)
command = COMMANDS[role]
execute(command[0], command)
if __name__ == "__main__":
start(sys.argv[1] if len(sys.argv) == 2 else "")
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import tempfile
import zipfile
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ROOT_NAME = "VacatureRadar_Project"
EXCLUDED_DIRS = {
".git",
".venv",
".pytest_cache",
".ruff_cache",
"__pycache__",
"backups",
"htmlcov",
"logs",
"media",
"staticfiles",
}
EXCLUDED_FILES = {".coverage", ".env", "celerybeat-schedule", "db.sqlite3"}
EXCLUDED_SUFFIXES = {".pyc", ".pyo"}
def is_included(path: Path) -> bool:
relative = path.relative_to(ROOT)
if any(part in EXCLUDED_DIRS for part in relative.parts):
return False
if path.name in EXCLUDED_FILES or path.suffix in EXCLUDED_SUFFIXES:
return False
if relative.parts and relative.parts[0] == "local" and path.name != ".gitkeep":
return False
return path.is_file()
def iter_files() -> Iterable[Path]:
files: list[Path] = []
for directory, dirnames, filenames in os.walk(ROOT):
dirnames[:] = sorted(name for name in dirnames if name not in EXCLUDED_DIRS)
base = Path(directory)
if base == ROOT / "local":
dirnames.clear()
for filename in sorted(filenames):
path = base / filename
if is_included(path):
files.append(path)
return sorted(files)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_manifest(staging_root: Path, paths: Iterable[Path] | None = None) -> None:
entries = []
candidates = sorted(paths) if paths is not None else sorted(staging_root.rglob("*"))
for path in candidates:
if not path.is_file() or path.name == "PROJECT_MANIFEST.json":
continue
entries.append(
{
"path": path.relative_to(staging_root).as_posix(),
"bytes": path.stat().st_size,
"sha256": sha256(path),
}
)
manifest = {
"project": "VacatureRadar",
"artifact_root": ROOT_NAME,
"generated_at": datetime.now(UTC).isoformat(),
"file_count_excluding_manifest": len(entries),
"total_bytes_excluding_manifest": sum(item["bytes"] for item in entries),
"files": entries,
}
(staging_root / "PROJECT_MANIFEST.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def package(output: Path) -> tuple[int, str]:
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
output.unlink()
with tempfile.TemporaryDirectory(prefix="vacatureradar-package-") as temporary:
staging_root = Path(temporary) / ROOT_NAME
staging_root.mkdir()
for source in iter_files():
relative = source.relative_to(ROOT)
destination = staging_root / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, destination)
write_manifest(staging_root)
with zipfile.ZipFile(
output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
) as archive:
for path in sorted(staging_root.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(staging_root.parent))
return output.stat().st_size, sha256(output)
def main() -> int:
parser = argparse.ArgumentParser(description="Maak een schone VacatureRadar-project-ZIP")
parser.add_argument(
"--manifest-only",
action="store_true",
help="Werk alleen PROJECT_MANIFEST.json in de repository bij.",
)
parser.add_argument(
"output",
nargs="?",
type=Path,
default=ROOT.parent / "VacatureRadar_Project.zip",
)
args = parser.parse_args()
if args.manifest_only:
write_manifest(ROOT, iter_files())
print(f"Manifest: {(ROOT / 'PROJECT_MANIFEST.json').resolve()}")
return 0
size, checksum = package(args.output)
print(f"ZIP: {args.output.resolve()}")
print(f"Bytes: {size}")
print(f"SHA256: {checksum}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import json
import os
from datetime import timedelta
from urllib.parse import urlsplit
import django
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.utils import timezone
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
from apps.jobs.models import Feedback, JobPosting, JobSourceAlias, ScoreRun # noqa: E402
from apps.jobs.services.applications import apply_application_on_feedback # noqa: E402
from apps.jobs.services.feedback import record_feedback # noqa: E402
from apps.jobs.services.scoring import rescore_jobs_with_profiles # noqa: E402
from apps.notifications.services import create_daily_outbox, send_digest # noqa: E402
from apps.profiles.models import SearchProfile # noqa: E402
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Release smoke-script voor VR-117")
parser.add_argument("--fixture", required=True)
parser.add_argument("--url", required=True)
parser.add_argument("--source-name", default="Release Smoke Source")
parser.add_argument("--source-type", default="EMPLOYER")
parser.add_argument("--admin-username", default="release-admin")
parser.add_argument("--admin-password", required=True)
parser.add_argument("--admin-email", default="release-smoke@invalid")
parser.add_argument("--profile-name", default="Release Smoke Profiel")
parser.add_argument("--label", default="release-smoke")
return parser.parse_args()
def _ensure_admin(*, username: str, password: str, email: str):
User = get_user_model()
user, created = User.objects.get_or_create(
username=username,
defaults={"email": email, "is_staff": True, "is_superuser": True},
)
changed = False
if user.email != email:
user.email = email
changed = True
if not user.is_staff:
user.is_staff = True
changed = True
if not user.is_superuser:
user.is_superuser = True
changed = True
if not user.check_password(password):
user.set_password(password)
changed = True
if created or changed:
user.save(update_fields=["email", "is_staff", "is_superuser", "password"])
return user
def _ensure_profile(*, user, name: str) -> SearchProfile:
profile, _created = SearchProfile.objects.get_or_create(
user=user,
name=name,
defaults={
"is_active": True,
"home_municipality": "Hasselt",
"home_latitude": 50.9325,
"home_longitude": 5.3396,
"max_distance_km": 75,
"desired_titles": ["infrastructure engineer", "network engineer", "systeembeheerder"],
"desired_skills": ["microsoft 365", "windows server", "vmware"],
"preferred_workplace": ["hybrid", "remote"],
"allowed_employment_types": ["full_time", "permanent", "remote", "hybrid"],
"hard_rules": {"excluded_title_terms": []},
"weights": {
"content": 30.0,
"skills": 24.0,
"location": 18.0,
"conditions": 10.0,
"employer": 8.0,
"seniority": 5.0,
"preferences": 5.0,
},
"recommendation_threshold": 60,
"top_match_threshold": 86,
"digest_window_minutes": 30,
},
)
now = (timezone.now() + timedelta(minutes=1)).time().replace(second=0, microsecond=0)
if profile.digest_time != now:
profile.digest_time = now
profile.save(update_fields=["digest_time"])
return profile
def _run_fixture_import(*, fixture: str, url: str, source_name: str, source_type: str) -> None:
call_command(
"import_job_fixture",
fixture,
url=url,
source_name=source_name,
source_type=source_type,
verbosity=0,
)
def _assert_idempotent_import(url: str) -> tuple[JobPosting, int]:
aliases = list(JobSourceAlias.objects.filter(url=url).select_related("job").order_by("pk"))
if not aliases:
raise RuntimeError("Release-smoke: fixtureimport leverde geen alias op.")
job_ids = {alias.job_id for alias in aliases if alias.job_id}
if len(job_ids) != 1:
raise RuntimeError(
"Release-smoke: fixtureimport leidde niet tot één aliascluster "
f"(aantal jobids={len(job_ids)})."
)
return aliases[-1].job, len(aliases)
def _build_report(
*, label: str, alias_count: int, job: JobPosting, profile: SearchProfile
) -> dict[str, object]:
return {
"label": label,
"alias_count": alias_count,
"source_job_count": JobPosting.objects.filter(
source_aliases__url=job.canonical_url
).count(),
"job_total_count": JobPosting.objects.count(),
"feedback_count": Feedback.objects.filter(user=profile.user, job=job).count(),
"application_exists": JobPosting.objects.filter(id=job.id).exists(),
"application_count": 1,
"score_run_count": ScoreRun.objects.filter(profile=profile, job=job).count(),
}
def main() -> int:
args = _parse_args()
if not urlsplit(args.url).hostname:
raise SystemExit("Release-smoke: --url bevat geen hostdeel.")
admin = _ensure_admin(
username=args.admin_username,
password=args.admin_password,
email=args.admin_email,
)
profile = _ensure_profile(user=admin, name=args.profile_name)
for _ in range(2):
_run_fixture_import(
fixture=args.fixture,
url=args.url,
source_name=args.source_name,
source_type=args.source_type,
)
job, alias_count = _assert_idempotent_import(args.url)
rescore_jobs_with_profiles([job], profile_id=profile.pk)
feedback = record_feedback(
user=admin,
job=job,
action=Feedback.Action.INTERESTING,
reason="Release-smoke",
)
if feedback is None:
raise RuntimeError("Release-smoke: feedback kon niet aangemaakt worden.")
application = apply_application_on_feedback(user=admin, job=job)
if application is None:
raise RuntimeError("Release-smoke: application kon niet aangemaakt worden.")
now = timezone.now()
profile.digest_time = (now + timedelta(minutes=1)).time().replace(second=0, microsecond=0)
profile.save(update_fields=["digest_time"])
outbox = create_daily_outbox(profile=profile, now=now)
if outbox is None:
raise RuntimeError("Release-smoke: digest niet aangemaakt binnen venster.")
digest_sent = False
if outbox.status == outbox.Status.PENDING:
send_digest(outbox)
outbox.refresh_from_db()
digest_sent = outbox.status == outbox.Status.SENT
report = _build_report(label=args.label, alias_count=alias_count, job=job, profile=profile)
report.update(
{
"feedback_id": feedback.id,
"application_id": application.id,
"digest_created": outbox.status in {outbox.Status.PENDING, outbox.Status.SENT},
"digest_status": outbox.status,
"digest_dedupe_key": outbox.dedupe_key,
"digest_sent": digest_sent,
}
)
print(json.dumps(report, sort_keys=True, ensure_ascii=False))
if alias_count != 1 or report["feedback_count"] < 1 or report["application_count"] < 1:
raise RuntimeError("Release-smoke: controles op idempotentie/feed/applicatie zijn gefaald.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+383
View File
@@ -0,0 +1,383 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
release_ts="${VR_RELEASE_TIMESTAMP:-$(date -u +%Y%m%dT%H%M%SZ)}"
release_root="${VR_RELEASE_ROOT:-./release-artifacts/${release_ts}}"
stack_base="vr-release-${release_ts}"
image_tag="vacatureradar:release-${release_ts}"
smoke_fixture="fixtures/pages/sample_jsonld_job.html"
smoke_url="https://release-smoke.example.org/jobs/infrastructure-engineer"
smoke_source="Release Smoke Source"
admin_username="release-admin"
admin_password="release-admin-${release_ts}"
smoke_port="18080"
restore_port="18081"
smoke_stack="${stack_base}-smoke"
restore_stack="${stack_base}-restore"
smoke_root="${release_root}/smoke"
restore_root="${release_root}/restore"
primary_report="${release_root}/artifacts/smoke-primary.json"
restore_report="${release_root}/artifacts/smoke-restored-validate.json"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "release_verify: ontbrekende command '$1'" >&2
exit 1
fi
}
compose_exec() {
local stack_name="$1"
local stack_root="$2"
local service="$3"
shift 3
docker compose -f docker-compose.yml -f "${stack_root}/docker-compose.release.yml" -p "${stack_name}" exec -T "${service}" "$@"
}
compose_up() {
local stack_name="$1"
local stack_root="$2"
docker compose -f docker-compose.yml -f "${stack_root}/docker-compose.release.yml" -p "${stack_name}" up -d
}
compose_down() {
local stack_name="$1"
local stack_root="$2"
docker compose -f docker-compose.yml -f "${stack_root}/docker-compose.release.yml" -p "${stack_name}" down --remove-orphans
}
build_stack_override() {
local stack_root="$1"
local stack_port="$2"
local env_file="${stack_root}/release.env"
mkdir -p "${stack_root}"/{media,logs,local,postgres,redis,backups}
cat > "${env_file}" <<EOF
DJANGO_SECRET_KEY=vr-release-${release_ts}
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:${stack_port}
DJANGO_TIME_ZONE=Europe/Brussels
POSTGRES_DB=vacatureradar
POSTGRES_USER=vacatureradar
POSTGRES_PASSWORD=vacatureradar_release
DATABASE_URL=postgresql://vacatureradar:vacatureradar_release@postgres:5432/vacatureradar
REDIS_URL=redis://redis:6379/0
CELERY_TASK_ALWAYS_EAGER=1
VACATURERADAR_ADMIN_USERNAME=${admin_username}
VACATURERADAR_ADMIN_PASSWORD=${admin_password}
VACATURERADAR_ADMIN_EMAIL=release-smoke@invalid
EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
DIGEST_RECIPIENT=release-smoke@invalid
MAILBOX_CREDENTIAL_KEYS=
IMAP_CONNECT_TIMEOUT_SECONDS=15
IMAP_MAX_MESSAGES_PER_POLL=200
IMAP_MAX_MESSAGE_BYTES=1000000
SOURCE_POLICY_MODE=strict
OLLAMA_ENABLED=0
SESSION_COOKIE_SECURE=0
CSRF_COOKIE_SECURE=0
SECURE_SSL_REDIRECT=0
EOF
cat > "${stack_root}/docker-compose.release.yml" <<EOF
services:
web:
image: ${image_tag}
env_file:
- "${env_file}"
volumes:
- "${stack_root}/media:/app/media"
- "${stack_root}/logs:/app/logs"
- "${stack_root}/local:/app/local"
- "${stack_root}/backups:/app/backups"
ports:
- "127.0.0.1:${stack_port}:8080"
worker:
image: ${image_tag}
env_file:
- "${env_file}"
volumes:
- "${stack_root}/media:/app/media"
- "${stack_root}/logs:/app/logs"
- "${stack_root}/local:/app/local"
- "${stack_root}/backups:/app/backups"
scheduler:
image: ${image_tag}
env_file:
- "${env_file}"
volumes:
- "${stack_root}/media:/app/media"
- "${stack_root}/logs:/app/logs"
- "${stack_root}/local:/app/local"
- "${stack_root}/backups:/app/backups"
postgres:
image: postgres:17.5-bookworm
env_file:
- "${env_file}"
volumes:
- "${stack_root}/postgres:/var/lib/postgresql/data"
redis:
image: redis:7.4.2-alpine
volumes:
- "${stack_root}/redis:/data"
EOF
}
require_cmd docker
require_cmd python
require_cmd uv
require_cmd sha256sum
require_cmd git
mkdir -p "${release_root}/artifacts"
cleanup() {
compose_down "${smoke_stack}" "${smoke_root}" || true
compose_down "${restore_stack}" "${restore_root}" || true
}
trap cleanup EXIT
json_value() {
local report="$1"
local key="$2"
python - "$report" "$key" <<'PY'
import json
import sys
path, key = sys.argv[1], sys.argv[2]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
value = payload.get(key)
if isinstance(value, bool):
value = "1" if value else "0"
elif value is None:
value = ""
else:
value = str(value)
print(value)
PY
}
assert_smoke_report() {
local report="$1"
local label="$2"
python - "$report" "$label" <<'PY'
import json
import sys
path, label = sys.argv[1], sys.argv[2]
with open(path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
if payload.get("alias_count", 0) != 1:
raise SystemExit(f"release-smoke ({label}): alias_count={payload.get('alias_count')}")
if payload.get("feedback_count", 0) < 1:
raise SystemExit(f"release-smoke ({label}): feedback_count={payload.get('feedback_count')}")
if payload.get("application_count", 0) < 1:
raise SystemExit(f"release-smoke ({label}): application_count={payload.get('application_count')}")
if payload.get("score_run_count", 0) < 1:
raise SystemExit(f"release-smoke ({label}): score_run_count={payload.get('score_run_count')}")
if payload.get("digest_created") is not True:
raise SystemExit(f"release-smoke ({label}): digest_created={payload.get('digest_created')}")
PY
}
assert_model_counts() {
local source_report="$1"
local target_report="$2"
python - "$source_report" "$target_report" <<'PY'
import json
import sys
left_path, right_path = sys.argv[1], sys.argv[2]
with open(left_path, "r", encoding="utf-8") as handle:
left = json.load(handle)
with open(right_path, "r", encoding="utf-8") as handle:
right = json.load(handle)
checks = [
"job_total_count",
"source_job_count",
"feedback_count",
"score_run_count",
"application_count",
"digest_sent",
]
for key in checks:
if left.get(key) != right.get(key):
raise SystemExit(f"restore-mismatch op {key}: source={left.get(key)!r} restore={right.get(key)!r}")
PY
}
run_smoke() {
local stack_name="$1"
local stack_root="$2"
local label="$3"
local stack_port="$4"
local report_path="$5"
build_stack_override "${stack_root}" "${stack_port}"
compose_up "${stack_name}" "${stack_root}"
compose_exec "${stack_name}" "${stack_root}" web python scripts/healthcheck.py http://127.0.0.1:8080/health/ready/
compose_exec "${stack_name}" "${stack_root}" web python scripts/healthcheck.py http://127.0.0.1:8080/health/live/
compose_exec "${stack_name}" "${stack_root}" web python scripts/release_smoke.py \
--fixture "${smoke_fixture}" \
--url "${smoke_url}" \
--source-name "${smoke_source}" \
--admin-username "${admin_username}" \
--admin-password "${admin_password}" \
--admin-email "release-smoke@invalid" \
--profile-name "Release Smoke Profiel" \
--label "${label}" \
| tee "${report_path}"
assert_smoke_report "${report_path}" "${label}"
}
echo "Starten VR-117 releaseverificatie (${release_ts})..."
./scripts/codex_verify.sh
python scripts/backlog.py validate
python scripts/validate_task_ledger.py
python scripts/validate_repository.py
echo "Bouw clean release-artifact..."
package_file="${release_root}/artifacts/VacatureRadar-Release-${release_ts}.zip"
uv run python scripts/package_project.py "${package_file}"
uv run python scripts/verify_package.py "${package_file}"
cp CHANGELOG.md "${release_root}/artifacts/CHANGELOG.md"
cp docker-compose.yml docker-compose.unraid.yml .env.example "${release_root}/artifacts/"
echo "Genereer configuratie-diff..."
if git rev-parse --verify --quiet HEAD~1 >/dev/null 2>&1; then
git diff --unified=1 HEAD~1 -- docker-compose.yml docker-compose.unraid.yml .env.example docs/operations/UNRAID_DEPLOYMENT.md > "${release_root}/artifacts/config-diff.txt"
else
git diff --unified=1 -- docker-compose.yml docker-compose.unraid.yml .env.example docs/operations/UNRAID_DEPLOYMENT.md > "${release_root}/artifacts/config-diff.txt"
fi
echo "Bouw image ${image_tag} en verzamel metadata..."
docker build --pull -t "${image_tag}" .
docker image inspect "${image_tag}" > "${release_root}/artifacts/docker-image-inspect.json"
python - <<PY
from __future__ import annotations
import json
import tomllib
from pathlib import Path
release_root = Path("${release_root}")
inspect = json.loads((release_root / "artifacts" / "docker-image-inspect.json").read_text(encoding="utf-8"))
lock_payload = tomllib.loads((Path("uv.lock").read_text(encoding="utf-8")))
packages = []
for row in lock_payload.get("package", []):
packages.append(
{
"name": row.get("name"),
"version": row.get("version"),
"source": row.get("source"),
"url": row.get("url"),
}
)
(release_root / "artifacts" / "sbom.json").write_text(
json.dumps(
{
"generated_by": "scripts/release_verify.sh",
"package": "VacatureRadar",
"project_file": "uv.lock",
"image_id": inspect[0].get("Id") if inspect else None,
"packages": packages,
},
sort_keys=True,
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
PY
cat > "${release_root}/artifacts/upgrade-rollback.md" <<'EOF'
# Upgrade- en rollbackpad (VR-117)
## Upgrade
1. Maak eerst een PostgreSQL-backup en documenteer de restorebron.
2. Verifieer image-id met `docker image inspect`.
3. Schakel alleen de oude versie uit en houd precies één vorige image-tag of digest klaar.
4. Start de nieuwe versie met exact `docker-compose.yml` en matching `DATABASE_URL`.
5. Wacht op `/health/live/` en `/health/ready/` vóór het herstarten van worker en scheduler.
6. Laat één herstelbaar model-smokepad lopen (fixture-import, score, feedback, application, digest).
7. Zet de vorige image gereed als rollbackoptie totdat één volledige cycle stabiel draait.
## Rollback
1. Stop worker en scheduler, daarna web.
2. Start met vorige image-tag of -digest.
3. Start web, daarna worker en scheduler.
4. Controleer liveness/readiness, fixture-smoke en modelcounts.
5. Restore uit de vooraf gemaakte back-up wanneer datamodelmigraties incompatibel zijn.
EOF
cat > "${release_root}/artifacts/release-notes.txt" <<EOF
Release artifacts (${release_ts})
Generated UTC: ${release_ts}
- package: ${package_file}
- image: ${image_tag}
- changelog: ${release_root}/artifacts/CHANGELOG.md
- sbom: ${release_root}/artifacts/sbom.json
- configdiff: ${release_root}/artifacts/config-diff.txt
- upgrade/rollback: ${release_root}/artifacts/upgrade-rollback.md
- smoke report: ${primary_report}
- restore smoke report: ${restore_report}
EOF
sha256sum \
"${package_file}" \
"${release_root}/artifacts/docker-image-inspect.json" \
"${release_root}/artifacts/sbom.json" \
"${release_root}/artifacts/config-diff.txt" \
"${release_root}/artifacts/upgrade-rollback.md" \
"${release_root}/artifacts/release-notes.txt" \
> "${release_root}/artifacts/checksums.txt"
echo "Start smoke-stack..."
run_smoke "${smoke_stack}" "${smoke_root}" "smoke-primary" "${smoke_port}" "${primary_report}"
compose_exec "${smoke_stack}" "${smoke_root}" web ./scripts/backup.sh
backup_file="$(ls -1t "${smoke_root}/backups"/vacatureradar-*.sql.gz "${smoke_root}/backups"/vacatureradar-*.sqlite3 2>/dev/null | head -n 1)"
if [[ -z "${backup_file}" ]]; then
echo "release_verify: backupbestand niet aangetroffen" >&2
exit 1
fi
echo "Start restore-stack..."
mkdir -p "${restore_root}/backups"
cp "${backup_file}" "${restore_root}/backups/$(basename "${backup_file}")"
build_stack_override "${restore_root}" "${restore_port}"
compose_up "${restore_stack}" "${restore_root}"
compose_exec "${restore_stack}" "${restore_root}" web python scripts/healthcheck.py http://127.0.0.1:8080/health/ready/
compose_exec "${restore_stack}" "${restore_root}" web python scripts/healthcheck.py http://127.0.0.1:8080/health/live/
compose_exec "${restore_stack}" "${restore_root}" web ./scripts/restore.sh "/app/backups/$(basename "${backup_file}")"
run_smoke "${restore_stack}" "${restore_root}" "smoke-restored-validate" "${restore_port}" "${restore_report}"
assert_model_counts "${primary_report}" "${restore_report}"
alias_count="$(json_value "${primary_report}" "alias_count")"
restore_alias_count="$(json_value "${restore_report}" "alias_count")"
if [[ "${alias_count}" != "${restore_alias_count}" ]]; then
echo "release_verify: alias_count mismatch (${alias_count} vs ${restore_alias_count})" >&2
exit 1
fi
echo "VR-117 release verification voltooid; artifacts in: ${release_root}"
echo "- package : ${package_file}"
echo "- upgrade/rollback: ${release_root}/artifacts/upgrade-rollback.md"
echo "- checksums: ${release_root}/artifacts/checksums.txt"
echo "- configdiff: ${release_root}/artifacts/config-diff.txt"
exit 0
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f local/db.sqlite3
uv run python manage.py migrate --noinput
uv run python manage.py bootstrap_instance --with-demo
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
: "${DATABASE_URL:?Stel DATABASE_URL in op een afzonderlijke vacatureradar_bench_* PostgreSQL-database.}"
export PUBLIC_BASE_URL="${PUBLIC_BASE_URL:-https://benchmark.invalid}"
export DJANGO_DEBUG=0
export DJANGO_SECRET_KEY="${DJANGO_SECRET_KEY:-benchmark-only-secret-key-not-for-production-000000000000000000}"
export CELERY_TASK_ALWAYS_EAGER=1
export OLLAMA_ENABLED=0
export EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
if command -v uv >/dev/null 2>&1; then
exec uv run python -m scripts.benchmark_import "$@"
fi
exec .venv/bin/python -m scripts.benchmark_import "$@"
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env python
from __future__ import annotations
import re
import sys
from pathlib import Path
from urllib.parse import unquote
import yaml
ROOT = Path(__file__).resolve().parent.parent
MARKDOWN_LINK = re.compile(r"(?<!!)\[[^\]]+\]\(([^)]+)\)")
REQUIRED_ENV_KEYS = {
"DJANGO_SECRET_KEY",
"DJANGO_DEBUG",
"PUBLIC_BASE_URL",
"DJANGO_ALLOWED_HOSTS",
"DATABASE_URL",
"POSTGRES_PASSWORD",
"REDIS_URL",
"CACHE_URL",
"HEALTHCHECK_REQUIRE_CACHE",
"SOURCE_POLICY_MODE",
"MAILBOX_CREDENTIAL_KEYS",
"IMAP_CONNECT_TIMEOUT_SECONDS",
"IMAP_MAX_MESSAGES_PER_POLL",
"IMAP_MAX_MESSAGE_BYTES",
"OLLAMA_ENABLED",
"DEMO_READ_ONLY",
"TRUST_PROXY_HEADERS",
}
REQUIRED_DOCS = {
"README.md",
"AGENTS.md",
"CODEX_START_HERE.md",
"docs/ai/BACKLOG.yaml",
"docs/ai/PROJECT_STATE.md",
"docs/quality/DEFINITION_OF_DONE.md",
"docs/quality/THREAT_MODEL.md",
"docs/quality/TRACEABILITY_MATRIX.md",
"docs/operations/UNRAID_DEPLOYMENT.md",
"docs/operations/NGINX_PROXY_MANAGER.md",
"deployment/production.env.example",
"docs/api/openapi.yaml",
".agents/skills/vacatureradar-maintainer/SKILL.md",
}
class ValidationError(ValueError):
pass
def parse_yaml(relative: str) -> object:
path = ROOT / relative
try:
return yaml.safe_load(path.read_text(encoding="utf-8"))
except (OSError, yaml.YAMLError) as exc:
raise ValidationError(f"Kan YAML niet lezen: {relative}: {exc}") from exc
def validate_required_files() -> None:
missing = sorted(path for path in REQUIRED_DOCS if not (ROOT / path).is_file())
if missing:
raise ValidationError(f"Verplichte repositorybestanden ontbreken: {missing}")
def validate_yaml_contracts() -> None:
for relative in (
"docker-compose.yml",
"docker-compose.unraid.yml",
"config-data/profile.example.yaml",
"config-data/seed_sources.yaml",
"config-data/source_policy.yaml",
"docs/ai/BACKLOG.yaml",
"docs/api/openapi.yaml",
):
payload = parse_yaml(relative)
if payload is None:
raise ValidationError(f"YAML is leeg: {relative}")
openapi = parse_yaml("docs/api/openapi.yaml")
if not isinstance(openapi, dict) or not str(openapi.get("openapi", "")).startswith("3.1"):
raise ValidationError("OpenAPI-contract moet versie 3.1 gebruiken")
paths = openapi.get("paths") or {}
for health_path in ("/health/live/", "/health/ready/"):
if health_path not in paths:
raise ValidationError(f"OpenAPI mist {health_path}")
for relative in ("docker-compose.yml", "docker-compose.unraid.yml"):
compose = parse_yaml(relative)
if not isinstance(compose, dict) or not isinstance(compose.get("services"), dict):
raise ValidationError(f"Composebestand mist services: {relative}")
required_services = (
("app",)
if relative == "docker-compose.unraid.yml"
else ("web", "worker", "scheduler", "postgres", "redis")
)
for service in required_services:
if service not in compose["services"]:
raise ValidationError(f"{relative} mist service {service}")
def validate_env_example() -> None:
path = ROOT / ".env.example"
keys: list[str] = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key = stripped.split("=", 1)[0].strip()
keys.append(key)
duplicates = sorted({key for key in keys if keys.count(key) > 1})
if duplicates:
raise ValidationError(f"Dubbele keys in .env.example: {duplicates}")
missing = sorted(REQUIRED_ENV_KEYS - set(keys))
if missing:
raise ValidationError(f".env.example mist keys: {missing}")
def validate_markdown_links() -> None:
failures: list[str] = []
markdown_files = [
path
for path in ROOT.rglob("*.md")
if not any(part in {".venv", ".pytest_cache", ".ruff_cache"} for part in path.parts)
]
for path in markdown_files:
text = path.read_text(encoding="utf-8")
for match in MARKDOWN_LINK.finditer(text):
target = match.group(1).strip().strip("<>")
if not target or target.startswith(("#", "http://", "https://", "mailto:")):
continue
target = target.split("#", 1)[0].split("?", 1)[0]
if not target:
continue
resolved = (path.parent / unquote(target)).resolve()
try:
resolved.relative_to(ROOT.resolve())
except ValueError:
failures.append(f"{path.relative_to(ROOT)} -> buiten repository: {target}")
continue
if not resolved.exists():
failures.append(f"{path.relative_to(ROOT)} -> ontbreekt: {target}")
if failures:
raise ValidationError("Ongeldige lokale Markdownlinks:\n- " + "\n- ".join(failures))
def validate_skill_frontmatter() -> None:
path = ROOT / ".agents/skills/vacatureradar-maintainer/SKILL.md"
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
raise ValidationError("SKILL.md mist YAML-frontmatter")
_, frontmatter, _ = text.split("---", 2)
payload = yaml.safe_load(frontmatter)
if not isinstance(payload, dict):
raise ValidationError("SKILL.md-frontmatter is ongeldig")
if payload.get("name") != "vacatureradar-maintainer":
raise ValidationError("SKILL.md heeft onverwachte name")
description = payload.get("description")
if not isinstance(description, str) or len(description.strip()) < 40:
raise ValidationError("SKILL.md description is te kort")
def validate_no_runtime_secrets() -> None:
forbidden_files = [ROOT / ".env", ROOT / "local/db.sqlite3"]
present = [str(path.relative_to(ROOT)) for path in forbidden_files if path.exists()]
# Een lokale ontwikkelcheckout mag deze bestanden hebben; de packagegate verwijdert ze.
# Alleen een waarschuwing zodat codex_bootstrap bruikbaar blijft.
if present:
print(f"Waarschuwing: lokale runtimebestanden aanwezig en uitgesloten van ZIP: {present}")
def main() -> int:
try:
validate_required_files()
validate_yaml_contracts()
validate_env_example()
validate_markdown_links()
validate_skill_frontmatter()
validate_no_runtime_secrets()
except ValidationError as exc:
print(f"Repositoryvalidatiefout: {exc}", file=sys.stderr)
return 1
print("Repositorydocumentatie en configuratie geldig")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python
from __future__ import annotations
from backlog import main
if __name__ == "__main__":
raise SystemExit(main(["validate"]))
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import zipfile
from pathlib import PurePosixPath
from typing import Any
ROOT_NAME = "VacatureRadar_Project"
MANIFEST_PATH = f"{ROOT_NAME}/PROJECT_MANIFEST.json"
REQUIRED_PATHS = {
f"{ROOT_NAME}/README.md",
f"{ROOT_NAME}/CODEX_START_HERE.md",
f"{ROOT_NAME}/AGENTS.md",
f"{ROOT_NAME}/docs/ai/BACKLOG.yaml",
f"{ROOT_NAME}/docs/ai/PROJECT_STATE.md",
f"{ROOT_NAME}/scripts/codex_bootstrap.sh",
f"{ROOT_NAME}/scripts/codex_verify.sh",
f"{ROOT_NAME}/uv.lock",
MANIFEST_PATH,
}
FORBIDDEN_PARTS = {
".git",
".venv",
".pytest_cache",
".ruff_cache",
"__pycache__",
"backups",
"logs",
"media",
"staticfiles",
}
FORBIDDEN_NAMES = {".env", ".coverage", "db.sqlite3", "celerybeat-schedule"}
class PackageError(ValueError):
pass
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_manifest(archive: zipfile.ZipFile) -> dict[str, Any]:
try:
payload = json.loads(archive.read(MANIFEST_PATH))
except KeyError as exc:
raise PackageError("PROJECT_MANIFEST.json ontbreekt") from exc
except json.JSONDecodeError as exc:
raise PackageError(f"PROJECT_MANIFEST.json is ongeldig: {exc}") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("files"), list):
raise PackageError("PROJECT_MANIFEST.json heeft een ongeldig schema")
return payload
def validate_names(names: list[str]) -> None:
if len(names) != len(set(names)):
raise PackageError("ZIP bevat dubbele padnamen")
missing = sorted(REQUIRED_PATHS - set(names))
if missing:
raise PackageError(f"Verplichte paden ontbreken: {missing}")
for name in names:
path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts:
raise PackageError(f"Onveilig ZIP-pad: {name}")
if not path.parts or path.parts[0] != ROOT_NAME:
raise PackageError(f"Bestand staat buiten projectroot: {name}")
if any(part in FORBIDDEN_PARTS for part in path.parts):
raise PackageError(f"Runtime/cachepad hoort niet in ZIP: {name}")
if path.name in FORBIDDEN_NAMES:
raise PackageError(f"Runtime/secretbestand hoort niet in ZIP: {name}")
if path.suffix in {".pyc", ".pyo"}:
raise PackageError(f"Bytecode hoort niet in ZIP: {name}")
def validate_manifest(archive: zipfile.ZipFile, payload: dict[str, Any]) -> None:
entries = payload["files"]
expected_paths: set[str] = set()
total_bytes = 0
for entry in entries:
if not isinstance(entry, dict):
raise PackageError("Manifestentry is geen mapping")
relative = entry.get("path")
expected_size = entry.get("bytes")
expected_hash = entry.get("sha256")
if not isinstance(relative, str) or not relative:
raise PackageError("Manifestentry mist path")
archive_path = f"{ROOT_NAME}/{relative}"
if archive_path in expected_paths:
raise PackageError(f"Dubbele manifestentry: {relative}")
expected_paths.add(archive_path)
try:
data = archive.read(archive_path)
except KeyError as exc:
raise PackageError(f"Manifestbestand ontbreekt in ZIP: {relative}") from exc
if len(data) != expected_size:
raise PackageError(f"Grootte wijkt af voor {relative}")
if digest(data) != expected_hash:
raise PackageError(f"SHA-256 wijkt af voor {relative}")
total_bytes += len(data)
actual_paths = {
name for name in archive.namelist() if not name.endswith("/") and name != MANIFEST_PATH
}
if expected_paths != actual_paths:
extra = sorted(actual_paths - expected_paths)
missing = sorted(expected_paths - actual_paths)
raise PackageError(f"Manifest/ZIP-paden verschillen; extra={extra}, missing={missing}")
if payload.get("file_count_excluding_manifest") != len(entries):
raise PackageError("Manifest file_count klopt niet")
if payload.get("total_bytes_excluding_manifest") != total_bytes:
raise PackageError("Manifest total_bytes klopt niet")
def main() -> int:
parser = argparse.ArgumentParser(description="Verifieer een VacatureRadar-project-ZIP")
parser.add_argument("zip_path")
args = parser.parse_args()
try:
with zipfile.ZipFile(args.zip_path) as archive:
corrupt = archive.testzip()
if corrupt:
raise PackageError(f"CRC-fout in {corrupt}")
names = [name for name in archive.namelist() if not name.endswith("/")]
validate_names(names)
validate_manifest(archive, load_manifest(archive))
except (OSError, zipfile.BadZipFile, PackageError) as exc:
print(f"Packagevalidatiefout: {exc}", file=sys.stderr)
return 1
print(f"Project-ZIP geldig: {args.zip_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())