@@ -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())
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mkdir -p backups
|
||||
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
set -a
|
||||
[[ -f .env ]] && source .env
|
||||
set +a
|
||||
|
||||
if [[ -n "${DATABASE_URL:-}" && "${DATABASE_URL}" == postgresql* ]]; then
|
||||
command -v pg_dump >/dev/null || { echo "pg_dump ontbreekt" >&2; exit 1; }
|
||||
pg_dump "${DATABASE_URL}" | gzip -9 > "backups/vacatureradar-${stamp}.sql.gz"
|
||||
else
|
||||
db="local/db.sqlite3"
|
||||
[[ -f "$db" ]] || { echo "Geen SQLite-database gevonden" >&2; exit 1; }
|
||||
cp "$db" "backups/vacatureradar-${stamp}.sqlite3"
|
||||
fi
|
||||
|
||||
tar -czf "backups/vacatureradar-files-${stamp}.tar.gz" --exclude='media/tmp' media config-data .env.example 2>/dev/null || true
|
||||
sha256sum backups/*"${stamp}"* > "backups/SHA256SUMS-${stamp}.txt"
|
||||
echo "Back-up klaar in backups/"
|
||||
@@ -0,0 +1,961 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
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.test.utils import CaptureQueriesContext
|
||||
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 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 = round((len(ordered) - 1) * p / 100)
|
||||
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")
|
||||
|
||||
|
||||
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,
|
||||
requirements=payload["job"]["requirements"],
|
||||
benefits=payload["job"]["benefits"],
|
||||
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()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _query_capture() -> Any:
|
||||
original_force_debug_cursor = connection.force_debug_cursor
|
||||
connection.force_debug_cursor = True
|
||||
try:
|
||||
with CaptureQueriesContext(connection) as captured:
|
||||
yield captured
|
||||
finally:
|
||||
connection.force_debug_cursor = original_force_debug_cursor
|
||||
|
||||
|
||||
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] = []
|
||||
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)
|
||||
import_timings.append((perf_counter() - start) * 1000)
|
||||
import_query_count = len(captured_import.captured_queries)
|
||||
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 = len(captured_rescore.captured_queries)
|
||||
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 = len(captured_list.captured_queries)
|
||||
detail_query_count = len(captured_detail.captured_queries)
|
||||
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,
|
||||
"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,
|
||||
"timing_count": len(rescore_timings),
|
||||
},
|
||||
"dashboard": {
|
||||
"list_query_count": list_query_count,
|
||||
"detail_query_count": detail_query_count,
|
||||
"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())
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/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
|
||||
IMAP_ENABLED=0
|
||||
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
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
mkdir -p local
|
||||
uv run ruff check .
|
||||
uv run python manage.py check
|
||||
uv run python manage.py makemigrations --check --dry-run
|
||||
uv 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'
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
compose_file="${1:-docker-compose.yml}"
|
||||
env_file="${2:-.env}"
|
||||
app_port="${APP_PORT:-1226}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker niet gevonden. Installeer Docker en probeer opnieuw."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python >/dev/null 2>&1; then
|
||||
echo "python niet gevonden. docker-deploy vereist python voor secrets."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "curl niet gevonden; healthcheck wordt overgeslagen."
|
||||
export SKIP_HEALTHCHECK=1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$env_file" ]]; then
|
||||
: > "$env_file"
|
||||
chmod 600 "$env_file"
|
||||
echo "Aangemaakte $env_file"
|
||||
fi
|
||||
|
||||
generate_secret() {
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
python scripts/generate_secret.py
|
||||
else
|
||||
date +%s | sha256sum | cut -d' ' -f1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_env() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
local existing_line
|
||||
local existing_value
|
||||
|
||||
existing_line="$(grep -m1 "^${key}=" "$env_file" || true)"
|
||||
if [[ -z "$existing_line" ]]; then
|
||||
printf '%s=%s\n' "$key" "$value" >> "$env_file"
|
||||
return
|
||||
fi
|
||||
|
||||
existing_value="${existing_line#*=}"
|
||||
if [[ -n "$existing_value" && \
|
||||
"$existing_value" != CHANGE_ME* && \
|
||||
"$existing_value" != dev-only-change-me && \
|
||||
"$existing_value" != codex-local-only ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local temp_env
|
||||
temp_env="$(mktemp)"
|
||||
while IFS= read -r env_line; do
|
||||
if [[ "$env_line" == "${key}="* ]]; then
|
||||
printf '%s=%s\n' "$key" "$value" >> "$temp_env"
|
||||
else
|
||||
printf '%s\n' "$env_line" >> "$temp_env"
|
||||
fi
|
||||
done < "$env_file"
|
||||
mv "$temp_env" "$env_file"
|
||||
}
|
||||
|
||||
app_host="${APP_HOST:-${HOSTNAME:-localhost}}"
|
||||
app_scheme="${APP_SCHEME:-http}"
|
||||
app_hostnames="${APP_HOSTS:-$app_host}"
|
||||
app_origin="${APP_ORIGINS:-}"
|
||||
|
||||
if [[ -z "$app_origin" ]]; then
|
||||
if [[ "$app_scheme" == "https" ]]; then
|
||||
if [[ "$app_port" == "443" ]]; then
|
||||
app_origin="https://$app_host"
|
||||
else
|
||||
app_origin="https://$app_host:$app_port"
|
||||
fi
|
||||
else
|
||||
app_origin="http://$app_host:$app_port"
|
||||
fi
|
||||
fi
|
||||
|
||||
secret_key="$(generate_secret)"
|
||||
postgres_password="${POSTGRES_PASSWORD:-$(generate_secret)}"
|
||||
admin_password="${VACATURERADAR_ADMIN_PASSWORD:-$(generate_secret | cut -c 1-24)}"
|
||||
db_url="postgresql://vacatureradar:${postgres_password}@postgres:5432/vacatureradar"
|
||||
|
||||
debug="${DJANGO_DEBUG:-1}"
|
||||
if [[ "$debug" == "0" ]]; then
|
||||
session_secure="${SESSION_COOKIE_SECURE:-1}"
|
||||
csrf_secure="${CSRF_COOKIE_SECURE:-1}"
|
||||
ssl_redirect="${SECURE_SSL_REDIRECT:-1}"
|
||||
hsts_seconds="${SECURE_HSTS_SECONDS:-31536000}"
|
||||
else
|
||||
session_secure="${SESSION_COOKIE_SECURE:-0}"
|
||||
csrf_secure="${CSRF_COOKIE_SECURE:-0}"
|
||||
ssl_redirect="${SECURE_SSL_REDIRECT:-0}"
|
||||
hsts_seconds="${SECURE_HSTS_SECONDS:-0}"
|
||||
fi
|
||||
|
||||
ensure_env "DJANGO_SECRET_KEY" "$secret_key"
|
||||
ensure_env "DJANGO_DEBUG" "$debug"
|
||||
ensure_env "DJANGO_ALLOWED_HOSTS" "$app_hostnames"
|
||||
ensure_env "DJANGO_CSRF_TRUSTED_ORIGINS" "$app_origin"
|
||||
ensure_env "DJANGO_TIME_ZONE" "Europe/Brussels"
|
||||
ensure_env "POSTGRES_DB" "vacatureradar"
|
||||
ensure_env "POSTGRES_USER" "vacatureradar"
|
||||
ensure_env "POSTGRES_PASSWORD" "$postgres_password"
|
||||
ensure_env "POSTGRES_HOST" "postgres"
|
||||
ensure_env "POSTGRES_PORT" "5432"
|
||||
ensure_env "DATABASE_URL" "$db_url"
|
||||
ensure_env "REDIS_URL" "redis://redis:6379/0"
|
||||
ensure_env "CELERY_TASK_ALWAYS_EAGER" "0"
|
||||
ensure_env "VACATURERADAR_ADMIN_USERNAME" "admin"
|
||||
ensure_env "VACATURERADAR_ADMIN_PASSWORD" "$admin_password"
|
||||
ensure_env "VACATURERADAR_AUTO_BOOTSTRAP" "1"
|
||||
ensure_env "SESSION_COOKIE_SECURE" "$session_secure"
|
||||
ensure_env "CSRF_COOKIE_SECURE" "$csrf_secure"
|
||||
ensure_env "SECURE_SSL_REDIRECT" "$ssl_redirect"
|
||||
ensure_env "SECURE_HSTS_SECONDS" "$hsts_seconds"
|
||||
ensure_env "EMAIL_BACKEND" "django.core.mail.backends.console.EmailBackend"
|
||||
ensure_env "DEFAULT_FROM_EMAIL" "VacatureRadar <vacatureradar@localhost>"
|
||||
ensure_env "SOURCE_POLICY_MODE" "strict"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_MAX_ATTEMPTS" "8"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_WINDOW_SECONDS" "300"
|
||||
ensure_env "AUTH_LOGIN_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS" "12"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS" "120"
|
||||
ensure_env "MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS" "300"
|
||||
|
||||
docker compose -f "$compose_file" up -d --build
|
||||
|
||||
if [[ "${SKIP_HEALTHCHECK:-0}" != "1" ]]; then
|
||||
for i in {1..30}; do
|
||||
if curl -fsS "http://127.0.0.1:${app_port}/health/ready/" >/dev/null; then
|
||||
echo "Healthcheck geslaagd op poort ${app_port}."
|
||||
break
|
||||
fi
|
||||
if [[ "$i" == "30" ]]; then
|
||||
echo "Healthcheck niet geslaagd binnen 60 seconden."
|
||||
exit 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
docker compose -f "$compose_file" exec -T web python manage.py collectstatic --noinput
|
||||
|
||||
cat <<"EOF"
|
||||
Deploy klaargezet.
|
||||
|
||||
Applicatie:
|
||||
- open http://127.0.0.1:1226/
|
||||
- admin: admin / waarde uit $env_file
|
||||
|
||||
Volgende stappen:
|
||||
- Zet APP_HOST en APP_SCHEME=HTTPS voor publieke domeinstandaard.
|
||||
- Zet APP_ORIGINS en DJANGO_DEBUG=0 voor echte productie.
|
||||
EOF
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
mkdir -p /app/media /app/logs /app/local
|
||||
mkdir -p /tmp/celery
|
||||
python manage.py migrate --noinput
|
||||
|
||||
if [ "${VACATURERADAR_AUTO_BOOTSTRAP:-0}" = "1" ]; then
|
||||
python manage.py bootstrap_instance || true
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/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
|
||||
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
from secrets import token_urlsafe
|
||||
|
||||
print(token_urlsafe(64))
|
||||
@@ -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
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
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]:
|
||||
return sorted(path for path in ROOT.rglob("*") if is_included(path))
|
||||
|
||||
|
||||
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) -> None:
|
||||
entries = []
|
||||
for path in sorted(staging_root.rglob("*")):
|
||||
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(
|
||||
"output",
|
||||
nargs="?",
|
||||
type=Path,
|
||||
default=ROOT.parent / "VacatureRadar_Project.zip",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
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())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/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(
|
||||
f"Release-smoke: fixtureimport leidde niet tot één aliascluster (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())
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/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
|
||||
IMAP_ENABLED=0
|
||||
IMAP_MARK_SEEN=0
|
||||
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
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/vacatureradar}"
|
||||
APP_REPO_URL="${APP_REPO_URL:?APP_REPO_URL mist in omgeving}"
|
||||
APP_BRANCH="${APP_BRANCH:-main}"
|
||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.yml}"
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "git ontbreekt op de server. Installeer git om te kunnen updaten."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker ontbreekt op de server. Installeer docker om te kunnen deployen."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$APP_DIR")"
|
||||
|
||||
if [[ -d "$APP_DIR/.git" ]]; then
|
||||
cd "$APP_DIR"
|
||||
git remote set-url origin "$APP_REPO_URL" >/dev/null 2>&1 || true
|
||||
git fetch --depth=1 origin "$APP_BRANCH"
|
||||
git checkout "$APP_BRANCH"
|
||||
git reset --hard "origin/$APP_BRANCH"
|
||||
git clean -fd
|
||||
else
|
||||
if [[ -d "$APP_DIR" && -n "$(ls -A "$APP_DIR")" ]]; then
|
||||
echo "$APP_DIR bestaat en is geen git-repository. Geef een lege map of een lege APP_DIR op."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$APP_DIR"
|
||||
git clone --depth=1 --branch "$APP_BRANCH" "$APP_REPO_URL" "$APP_DIR"
|
||||
cd "$APP_DIR"
|
||||
fi
|
||||
|
||||
export APP_HOST="${APP_HOST:-${HOSTNAME:-localhost}}"
|
||||
export APP_SCHEME="${APP_SCHEME:-http}"
|
||||
export DJANGO_DEBUG="${DJANGO_DEBUG:-0}"
|
||||
|
||||
if [[ -n "${APP_ORIGINS:-}" ]]; then
|
||||
export APP_ORIGINS
|
||||
fi
|
||||
|
||||
if [[ -n "${APP_HOSTS:-}" ]]; then
|
||||
export APP_HOSTS
|
||||
fi
|
||||
|
||||
if [[ -n "${APP_PORT:-}" ]]; then
|
||||
export APP_PORT
|
||||
fi
|
||||
|
||||
bash scripts/deploy_docker.sh "$COMPOSE_FILE" "$ENV_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
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Gebruik: $0 /pad/naar/backup.sql.gz|backup.sqlite3" >&2
|
||||
exit 2
|
||||
fi
|
||||
cd "$(dirname "$0")/.."
|
||||
backup="$1"
|
||||
[[ -f "$backup" ]] || { echo "Back-up bestaat niet: $backup" >&2; exit 1; }
|
||||
|
||||
set -a
|
||||
[[ -f .env ]] && source .env
|
||||
set +a
|
||||
|
||||
case "$backup" in
|
||||
*.sql.gz)
|
||||
[[ -n "${DATABASE_URL:-}" ]] || { echo "DATABASE_URL ontbreekt" >&2; exit 1; }
|
||||
command -v psql >/dev/null || { echo "psql ontbreekt" >&2; exit 1; }
|
||||
gzip -dc "$backup" | psql "${DATABASE_URL}"
|
||||
;;
|
||||
*.sqlite3)
|
||||
mkdir -p local
|
||||
[[ ! -f local/db.sqlite3 ]] || cp local/db.sqlite3 "local/db.sqlite3.before-restore-$(date +%s)"
|
||||
cp "$backup" local/db.sqlite3
|
||||
;;
|
||||
*) echo "Onbekend back-upformaat" >&2; exit 1 ;;
|
||||
esac
|
||||
uv run python manage.py migrate --noinput
|
||||
uv run python manage.py check
|
||||
echo "Herstel voltooid; voer ./scripts/codex_verify.sh uit."
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/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",
|
||||
"DJANGO_ALLOWED_HOSTS",
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_PASSWORD",
|
||||
"REDIS_URL",
|
||||
"SOURCE_POLICY_MODE",
|
||||
"IMAP_ENABLED",
|
||||
"OLLAMA_ENABLED",
|
||||
}
|
||||
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/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}")
|
||||
for service in ("web", "worker", "scheduler", "postgres", "redis"):
|
||||
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())
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
from __future__ import annotations
|
||||
|
||||
from backlog import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(["validate"]))
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user