Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import re
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import yaml
DEFAULT_PATH = Path("docs/ai/BACKLOG.yaml")
VALID_STATUSES = {"done", "ready", "blocked-external", "deferred"}
VALID_PRIORITIES = {"P0", "P1", "P2", "P3"}
ID_PATTERN = re.compile(r"^VR-\d{3}$")
REQUIREMENT_PATTERN = re.compile(r"^(PR|NFR|AC)-\d{3}$")
REQUIRED_TASK_FIELDS = {
"id",
"title",
"status",
"priority",
"requirement_ids",
"depends_on",
"summary",
"acceptance_criteria",
"verification",
"primary_paths",
}
class LedgerError(ValueError):
pass
def load_ledger(path: Path = DEFAULT_PATH) -> dict[str, Any]:
try:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise LedgerError(f"Taakledger ontbreekt: {path}") from exc
except yaml.YAMLError as exc:
raise LedgerError(f"Ongeldige YAML in {path}: {exc}") from exc
if not isinstance(payload, dict):
raise LedgerError("Taakledger moet een mapping op topniveau zijn")
return payload
def _require_nonempty_list(task: dict[str, Any], field: str) -> None:
value = task.get(field)
if not isinstance(value, list) or not value:
raise LedgerError(f"{task.get('id', '<zonder-id>')}: {field} moet een niet-lege lijst zijn")
if any(not isinstance(item, str) or not item.strip() for item in value):
task_id = task.get("id", "<zonder-id>")
raise LedgerError(f"{task_id}: {field} bevat een lege/niet-tekst waarde")
def validate_ledger(payload: dict[str, Any]) -> list[dict[str, Any]]:
if payload.get("schema_version") != 1:
raise LedgerError("Alleen schema_version 1 wordt ondersteund")
tasks = payload.get("tasks")
if not isinstance(tasks, list) or not tasks:
raise LedgerError("BACKLOG.yaml moet minstens één taak bevatten")
ids: list[str] = []
task_by_id: dict[str, dict[str, Any]] = {}
for raw in tasks:
if not isinstance(raw, dict):
raise LedgerError("Iedere taak moet een mapping zijn")
missing = REQUIRED_TASK_FIELDS - raw.keys()
if missing:
task_id = raw.get("id", "<zonder-id>")
raise LedgerError(f"{task_id}: ontbrekende velden {sorted(missing)}")
task_id = raw["id"]
if not isinstance(task_id, str) or not ID_PATTERN.fullmatch(task_id):
raise LedgerError(f"Ongeldige taak-ID: {task_id!r}")
if task_id in task_by_id:
raise LedgerError(f"Dubbele taak-ID: {task_id}")
if raw["status"] not in VALID_STATUSES:
raise LedgerError(f"{task_id}: ongeldige status {raw['status']!r}")
if raw["priority"] not in VALID_PRIORITIES:
raise LedgerError(f"{task_id}: ongeldige priority {raw['priority']!r}")
if not isinstance(raw["title"], str) or not raw["title"].strip():
raise LedgerError(f"{task_id}: title ontbreekt")
if not isinstance(raw["summary"], str) or len(raw["summary"].strip()) < 20:
raise LedgerError(f"{task_id}: summary is te kort")
for field in ("requirement_ids", "acceptance_criteria", "verification", "primary_paths"):
_require_nonempty_list(raw, field)
for requirement in raw["requirement_ids"]:
if not REQUIREMENT_PATTERN.fullmatch(requirement):
raise LedgerError(f"{task_id}: ongeldige requirement-ID {requirement!r}")
if not isinstance(raw["depends_on"], list) or any(
not isinstance(item, str) for item in raw["depends_on"]
):
raise LedgerError(f"{task_id}: depends_on moet een lijst van IDs zijn")
if task_id in raw["depends_on"]:
raise LedgerError(f"{task_id}: taak mag niet van zichzelf afhangen")
if raw["status"] == "done":
result = raw.get("result")
has_result = (
isinstance(result, dict)
and bool(result.get("completed_at"))
and bool(result.get("note"))
)
if not has_result:
raise LedgerError(
f"{task_id}: done taak vereist result.completed_at en result.note"
)
if raw["status"] == "blocked-external" and not raw.get("external_blocker"):
raise LedgerError(f"{task_id}: blocked-external vereist external_blocker")
ids.append(task_id)
task_by_id[task_id] = raw
for task in tasks:
for dependency in task["depends_on"]:
if dependency not in task_by_id:
raise LedgerError(f"{task['id']}: onbekende dependency {dependency}")
visiting: set[str] = set()
visited: set[str] = set()
def visit(task_id: str, trail: list[str]) -> None:
if task_id in visiting:
cycle = " -> ".join([*trail, task_id])
raise LedgerError(f"Dependencycyclus: {cycle}")
if task_id in visited:
return
visiting.add(task_id)
for dependency in task_by_id[task_id]["depends_on"]:
visit(dependency, [*trail, task_id])
visiting.remove(task_id)
visited.add(task_id)
for task_id in ids:
visit(task_id, [])
for task in tasks:
if task["status"] == "done":
incomplete_dependencies = [
dep for dep in task["depends_on"] if task_by_id[dep]["status"] != "done"
]
if incomplete_dependencies:
raise LedgerError(
f"{task['id']}: done maar dependencies niet done: {incomplete_dependencies}"
)
return tasks
def save_ledger(payload: dict[str, Any], path: Path = DEFAULT_PATH) -> None:
payload["updated_at"] = datetime.now(UTC).date().isoformat()
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=110),
encoding="utf-8",
)
temporary.replace(path)
def executable_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]:
status_by_id = {task["id"]: task["status"] for task in tasks}
return [
task
for task in tasks
if task["status"] == "ready"
and all(status_by_id[dependency] == "done" for dependency in task["depends_on"])
]
def print_task(task: dict[str, Any], *, verbose: bool = True) -> None:
print(f"{task['id']} [{task['priority']}/{task['status']}] {task['title']}")
if not verbose:
return
print(f"\n{task['summary']}")
if task["depends_on"]:
print(f"\nDependencies: {', '.join(task['depends_on'])}")
print("\nAcceptatiecriteria:")
for item in task["acceptance_criteria"]:
print(f"- {item}")
print("\nVerificatie:")
for item in task["verification"]:
print(f"- {item}")
print("\nPrimaire paden:")
for item in task["primary_paths"]:
print(f"- {item}")
if task.get("external_blocker"):
print(f"\nExterne blokkade: {task['external_blocker']}")
if task.get("result"):
print(f"\nResultaat: {task['result'].get('note', '')}")
def command_validate(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
counts = {status: sum(task["status"] == status for task in tasks) for status in VALID_STATUSES}
executable = executable_tasks(tasks)
print(
"Taakledger geldig: "
f"{len(tasks)} taken; "
+ ", ".join(f"{status}={counts[status]}" for status in sorted(counts))
+ f"; uitvoerbaar={len(executable)}"
)
return 0
def command_next(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
executable = executable_tasks(tasks)
if not executable:
print("Geen uitvoerbare ready taak. Controleer blocked-external en deferred taken.")
return 1
print_task(executable[0], verbose=not args.id_only)
return 0
def command_list(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
selected = [task for task in tasks if not args.status or task["status"] == args.status]
for task in selected:
print_task(task, verbose=False)
return 0
def command_show(args: argparse.Namespace) -> int:
tasks = validate_ledger(load_ledger(args.path))
for task in tasks:
if task["id"] == args.task_id:
print_task(task)
return 0
raise LedgerError(f"Onbekende taak: {args.task_id}")
def command_set(args: argparse.Namespace) -> int:
payload = load_ledger(args.path)
tasks = validate_ledger(payload)
task_by_id = {task["id"]: task for task in tasks}
try:
target = task_by_id[args.task_id]
except KeyError as exc:
raise LedgerError(f"Onbekende taak: {args.task_id}") from exc
if args.status == "done":
incomplete = [dep for dep in target["depends_on"] if task_by_id[dep]["status"] != "done"]
if incomplete:
raise LedgerError(
f"Kan {args.task_id} niet afronden; dependencies niet done: {incomplete}"
)
if not args.note:
raise LedgerError("Status done vereist --note met het geverifieerde resultaat")
target["result"] = {
"completed_at": datetime.now(UTC).date().isoformat(),
"note": args.note.strip(),
}
target.pop("external_blocker", None)
elif args.status == "blocked-external":
if not args.note:
raise LedgerError("blocked-external vereist --note met de exacte externe blokkade")
target["external_blocker"] = args.note.strip()
target.pop("result", None)
else:
target.pop("result", None)
if args.status != "blocked-external":
target.pop("external_blocker", None)
target["status"] = args.status
save_ledger(payload, args.path)
validate_ledger(load_ledger(args.path))
print_task(target, verbose=False)
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="VacatureRadar machineleesbare backlog beheren")
parser.add_argument("--path", type=Path, default=DEFAULT_PATH)
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate", help="Valideer schema en dependencygraaf")
validate_parser.set_defaults(func=command_validate)
next_parser = subparsers.add_parser("next", help="Toon de eerste uitvoerbare ready taak")
next_parser.add_argument("--id-only", action="store_true", help="Toon alleen de kopregel")
next_parser.set_defaults(func=command_next)
list_parser = subparsers.add_parser("list", help="Toon taken")
list_parser.add_argument("--status", choices=sorted(VALID_STATUSES))
list_parser.set_defaults(func=command_list)
show_parser = subparsers.add_parser("show", help="Toon één taak volledig")
show_parser.add_argument("task_id")
show_parser.set_defaults(func=command_show)
set_parser = subparsers.add_parser("set", help="Wijzig een taakstatus")
set_parser.add_argument("task_id")
set_parser.add_argument("status", choices=sorted(VALID_STATUSES))
set_parser.add_argument("--note", default="")
set_parser.set_defaults(func=command_set)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except LedgerError as exc:
print(f"Taakledgerfout: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+22
View File
@@ -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/"
+961
View File
@@ -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())
+42
View File
@@ -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
+12
View File
@@ -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'
+164
View File
@@ -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
+12
View File
@@ -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 "$@"
+125
View File
@@ -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())
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env python
from secrets import token_urlsafe
print(token_urlsafe(64))
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python
from __future__ import annotations
import json
import sys
import urllib.error
import urllib.request
url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8080/health/ready/"
try:
with urllib.request.urlopen(url, timeout=4) as response: # noqa: S310 - vaste interne health-URL
payload = json.load(response)
if response.status != 200 or not payload.get("ok"):
raise SystemExit(1)
except (urllib.error.URLError, ValueError, TimeoutError):
raise SystemExit(1) from None
+121
View File
@@ -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())
+203
View File
@@ -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())
+381
View File
@@ -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
+56
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
rm -f local/db.sqlite3
uv run python manage.py migrate --noinput
uv run python manage.py bootstrap_instance --with-demo
+30
View File
@@ -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."
+174
View File
@@ -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())
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env python
from __future__ import annotations
from backlog import main
if __name__ == "__main__":
raise SystemExit(main(["validate"]))
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import zipfile
from pathlib import PurePosixPath
from typing import Any
ROOT_NAME = "VacatureRadar_Project"
MANIFEST_PATH = f"{ROOT_NAME}/PROJECT_MANIFEST.json"
REQUIRED_PATHS = {
f"{ROOT_NAME}/README.md",
f"{ROOT_NAME}/CODEX_START_HERE.md",
f"{ROOT_NAME}/AGENTS.md",
f"{ROOT_NAME}/docs/ai/BACKLOG.yaml",
f"{ROOT_NAME}/docs/ai/PROJECT_STATE.md",
f"{ROOT_NAME}/scripts/codex_bootstrap.sh",
f"{ROOT_NAME}/scripts/codex_verify.sh",
f"{ROOT_NAME}/uv.lock",
MANIFEST_PATH,
}
FORBIDDEN_PARTS = {
".git",
".venv",
".pytest_cache",
".ruff_cache",
"__pycache__",
"backups",
"logs",
"media",
"staticfiles",
}
FORBIDDEN_NAMES = {".env", ".coverage", "db.sqlite3", "celerybeat-schedule"}
class PackageError(ValueError):
pass
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_manifest(archive: zipfile.ZipFile) -> dict[str, Any]:
try:
payload = json.loads(archive.read(MANIFEST_PATH))
except KeyError as exc:
raise PackageError("PROJECT_MANIFEST.json ontbreekt") from exc
except json.JSONDecodeError as exc:
raise PackageError(f"PROJECT_MANIFEST.json is ongeldig: {exc}") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("files"), list):
raise PackageError("PROJECT_MANIFEST.json heeft een ongeldig schema")
return payload
def validate_names(names: list[str]) -> None:
if len(names) != len(set(names)):
raise PackageError("ZIP bevat dubbele padnamen")
missing = sorted(REQUIRED_PATHS - set(names))
if missing:
raise PackageError(f"Verplichte paden ontbreken: {missing}")
for name in names:
path = PurePosixPath(name)
if path.is_absolute() or ".." in path.parts:
raise PackageError(f"Onveilig ZIP-pad: {name}")
if not path.parts or path.parts[0] != ROOT_NAME:
raise PackageError(f"Bestand staat buiten projectroot: {name}")
if any(part in FORBIDDEN_PARTS for part in path.parts):
raise PackageError(f"Runtime/cachepad hoort niet in ZIP: {name}")
if path.name in FORBIDDEN_NAMES:
raise PackageError(f"Runtime/secretbestand hoort niet in ZIP: {name}")
if path.suffix in {".pyc", ".pyo"}:
raise PackageError(f"Bytecode hoort niet in ZIP: {name}")
def validate_manifest(archive: zipfile.ZipFile, payload: dict[str, Any]) -> None:
entries = payload["files"]
expected_paths: set[str] = set()
total_bytes = 0
for entry in entries:
if not isinstance(entry, dict):
raise PackageError("Manifestentry is geen mapping")
relative = entry.get("path")
expected_size = entry.get("bytes")
expected_hash = entry.get("sha256")
if not isinstance(relative, str) or not relative:
raise PackageError("Manifestentry mist path")
archive_path = f"{ROOT_NAME}/{relative}"
if archive_path in expected_paths:
raise PackageError(f"Dubbele manifestentry: {relative}")
expected_paths.add(archive_path)
try:
data = archive.read(archive_path)
except KeyError as exc:
raise PackageError(f"Manifestbestand ontbreekt in ZIP: {relative}") from exc
if len(data) != expected_size:
raise PackageError(f"Grootte wijkt af voor {relative}")
if digest(data) != expected_hash:
raise PackageError(f"SHA-256 wijkt af voor {relative}")
total_bytes += len(data)
actual_paths = {
name for name in archive.namelist() if not name.endswith("/") and name != MANIFEST_PATH
}
if expected_paths != actual_paths:
extra = sorted(actual_paths - expected_paths)
missing = sorted(expected_paths - actual_paths)
raise PackageError(f"Manifest/ZIP-paden verschillen; extra={extra}, missing={missing}")
if payload.get("file_count_excluding_manifest") != len(entries):
raise PackageError("Manifest file_count klopt niet")
if payload.get("total_bytes_excluding_manifest") != total_bytes:
raise PackageError("Manifest total_bytes klopt niet")
def main() -> int:
parser = argparse.ArgumentParser(description="Verifieer een VacatureRadar-project-ZIP")
parser.add_argument("zip_path")
args = parser.parse_args()
try:
with zipfile.ZipFile(args.zip_path) as archive:
corrupt = archive.testzip()
if corrupt:
raise PackageError(f"CRC-fout in {corrupt}")
names = [name for name in archive.namelist() if not name.endswith("/")]
validate_names(names)
validate_manifest(archive, load_manifest(archive))
except (OSError, zipfile.BadZipFile, PackageError) as exc:
print(f"Packagevalidatiefout: {exc}", file=sys.stderr)
return 1
print(f"Project-ZIP geldig: {args.zip_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())