# Interne contracten Dit document beschrijft de stabiele grenzen tussen domeinmodules. Het is geen belofte van een externe REST-API. De eerste release gebruikt Django-views, formulieren en servicefuncties; daardoor blijft de onderhoudslast laag en kan later zonder herschrijven een JSON-laag boven dezelfde services worden geplaatst. ## Contractprincipes 1. **Adapters lezen, services beslissen.** Een bronadapter maakt alleen een `ExtractionResult`; hij schrijft niet naar de database en voert geen scoring uit. 2. **Onbetrouwbare data blijft data.** HTML, e-mail, JSON-LD en modeluitvoer mogen nooit instructies of code worden. 3. **Persistente writes zijn atomair en idempotent.** Herhaalde import van dezelfde broninhoud maakt geen tweede vacature aan. 4. **Orchestrators bevatten geen productregels.** Celery-taken plannen en registreren; domeinservices normaliseren, dedupliceren en scoren. 5. **AI is optioneel.** Geen core-contract mag een taalmodel vereisen om geldige output te leveren. ## Bronadapter Implementatie: `apps/sources/adapters/base.py`. ```python class SourceAdapter(Protocol): parser_key: str parser_version: str def extract(self, content: str, *, url: str) -> ExtractionResult: ... ``` ### Invoer - `content`: reeds opgehaalde Unicode-inhoud; netwerktoegang vindt vóór de adapter plaats. - `url`: gevalideerde bron- of document-URL, uitsluitend voor context en relatieve URL-resolutie. ### Uitvoer `ExtractionResult` bevat: - `jobs`: nul of meer `ExtractedJob`-objecten; - `parser_key` en `parser_version`: reproduceerbare parseridentiteit; - `confidence`: globaal getal tussen `0.0` en `1.0`; - `warnings`: niet-fatale, korte, machineveilige waarschuwingen. Een `ExtractedJob` gebruikt zo veel mogelijk brongetrouwe waarden. Normalisatie gebeurt pas in `normalize_extracted_job`. Een adapter moet: - ontbrekende velden leeg laten in plaats van ze te verzinnen; - iedere URL absoluut maken; - beschrijving als bron-HTML én/of platte tekst leveren; - veldbewijs toevoegen wanneer een waarde heuristisch is gevonden; - nooit secrets, cookies, headers of volledige mailmetadata in `raw` opnemen; - geen exception gebruiken voor een geldig document met nul vacatures. ## Fetcher Implementatie: `apps/sources/services/fetcher.py` en `url_security.py`. ```python fetch_url( url: str, *, source: Source | None = None, conditional_headers: dict[str, str] | None = None, ) -> FetchedDocument ``` Voorwaarden vóór ieder request en na iedere redirect: - schema is `http` of `https`; - host is niet leeg, localhost, link-local, multicast, private of anderszins speciaal; - poort is 80/443, tenzij expliciet toegestaan; - domein is niet op de centrale platformdenylist; - bronstatus en bronbeleid laten toegang toe; - response blijft binnen tijd-, redirect- en bytegrenzen. `FetchedDocument` bevat de aangevraagde en uiteindelijke URL, status, gefilterde headers, bytes, tekst en SHA-256. Callers mogen de responsebody niet loggen. Machineleesbare foutcategorieën: - `PolicyBlockedError`: bron/domein is niet toegestaan; niet blind retrien; - `ContentRejectedError`: type, grootte of response is onbruikbaar; - `FetchError`: tijdelijke netwerk- of HTTP-fout die begrensd mag worden herhaald; - `UnsafeUrlError`: URL faalt SSRF-validatie vóór netwerktoegang. ## Normalisatie Implementatie: `apps/jobs/services/normalization.py`. ```python normalize_extracted_job(extracted: ExtractedJob) -> CanonicalJobDraft ``` De functie: - saneert HTML vóór opslag/rendering; - normaliseert titel, contracttypen, werkplek en taal; - canonicaliseert de URL en verwijdert gekende trackingparameters; - berekent een stabiele `canonical_key` en `content_hash`; - parseert datums conservatief en timezone-aware; - behoudt bronwaarden in `raw` en `evidence` voor uitlegbaarheid. Een `CanonicalJobDraft` is een immutable overdrachtsobject. Het kent geen database-ID en bevat geen score. ## Persistente pipeline Implementatie: `apps/jobs/services/pipeline.py`. ```python process_raw_document(document: RawDocument) -> dict[str, int | str | list[str]] ``` Transactionele volgorde: 1. kies adapter via registry; 2. extracteer jobs en parsermetadata; 3. normaliseer iedere job; 4. zoek een bestaand canoniek cluster; 5. maak of werk werkgever, vacature en bronalias bij; 6. leg veldherkomst en versie vast; 7. score tegen ieder actief zoekprofiel. De resultaatmapping bevat minimaal `extracted`, `created`, `updated`, `duplicates`, `parser` en `warnings`. De tellers moeten deterministisch blijven bij een replay van hetzelfde document. ## Deduplicatie Implementatie: `apps/jobs/services/dedupe.py`. ```python find_existing_job(draft: CanonicalJobDraft) -> DedupeDecision ``` `DedupeDecision` bevat: - `job`: bestaand canoniek object of `None`; - `reason`: machineleesbare reden, bijvoorbeeld `exact_canonical_key`, `exact_external_id` of `fuzzy_*`; - `confidence`: getal tussen `0.0` en `1.0`. Een fuzzy match mag alleen plaatsvinden wanneer werkgever, titel, locatie en periode voldoende bewijs leveren. Een rechtstreekse werkgeversbron krijgt canonieke voorkeur boven een platform- of recruiterlink; bronaliassen blijven behouden. ## Scoring Implementatie: `apps/jobs/services/scoring.py`. ```python score_job(job: JobPosting, profile: SearchProfile) -> ScoreResult score_and_save(job: JobPosting, profile: SearchProfile) -> ScoreRun ``` `ScoreResult` levert: - `score`: begrensd op 0–100; - `hard_exclusions`: stabiele regelcodes; - `explanation`: korte positieve en negatieve factoren; - `feature_values`: auditbare, niet-persoonsgevoelige featurewaarden; - `model_version`: versie van regels/gewichten. Harde uitsluitingen worden nooit door een hoge zachte score opgeheven. AI-output mag hoogstens aanvullende kenmerken leveren nadat schema- en confidencevalidatie zijn geslaagd. ## Feedback Implementatie: `apps/jobs/services/feedback.py`. Toegestane acties zijn `interesting`, `save`, `hide` en `applied`. Iedere mutatie is user-scoped en CSRF-beschermd. Feedback mag gewichten slechts binnen de in het profiel vastgelegde grenzen aanpassen. De gebruiker moet de wijziging kunnen resetten en een vacature handmatig terugvinden, ook wanneer ze is verborgen. ## Notificaties Implementatie: `apps/notifications/services.py`. Een digest wordt eerst als `DigestOutbox` aangemaakt en daarna verzonden. Uniciteit per profiel en digestdatum voorkomt dubbele mails. Verzending verandert alleen de outboxstatus; het opnieuw genereren van de selectie moet reproduceerbaar zijn uit opgeslagen `ScoreRun`-records. ## Nieuwe service toevoegen Een nieuwe interne service is pas een stabiel contract wanneer: - publieke types en foutcategorieën zijn gedocumenteerd; - unit- en integratietests succes, lege invoer en negatieve paden dekken; - netwerk- of secretvereisten injecteerbaar zijn; - er geen databasewrite in parser- of pure normalisatielogica zit; - traceability en backlog zijn bijgewerkt.