from __future__ import annotations from dataclasses import dataclass from typing import Any from django.db import transaction from apps.jobs.models import Feedback, JobPosting, ScoreRun from apps.jobs.services.applications import apply_application_on_feedback from apps.profiles.models import SearchProfile from apps.profiles.services import apply_feedback_delta LEARNING_MIN_SAMPLES = 2 LEARNING_DELTA_BY_ACTION = { Feedback.Action.INTERESTING: 1.0, Feedback.Action.SAVE: 0.75, Feedback.Action.HIDE: -1.0, } LEARNING_FEATURES = { "content", "skills", "location", "conditions", "employer", "seniority", "preferences", } @dataclass(frozen=True) class _LearningSignal: feature: str delta: float reason_code: str learnable: bool def _normalize_reason_text(reason: str | None) -> str: return (reason or "").strip().lower() def _latest_score_run(profile: SearchProfile, job: JobPosting) -> ScoreRun | None: return ScoreRun.objects.filter(profile=profile, job=job).order_by("-created_at").first() def _best_signal_feature(score_run: ScoreRun | None) -> str: if score_run is None: return "content" components = { key: value for key, value in (score_run.components or {}).items() if key in LEARNING_FEATURES } if not components: return "content" return max(components, key=components.get) def _classify_hide_signal( profile: SearchProfile, score_run: ScoreRun | None, reason: str ) -> _LearningSignal: normalized = _normalize_reason_text(reason) if not normalized: return _LearningSignal( feature="", delta=0.0, reason_code="implicit_hide_no_reason", learnable=False, ) if any(token in normalized for token in ("titel", "functie", "title", "titelomschrijving")): return _LearningSignal( feature="", delta=0.0, reason_code="non_learning_title", learnable=False, ) if any( token in normalized for token in ("afstand", "afstands", "km", "locatie", "verplaatsing") ): return _LearningSignal( feature="", delta=0.0, reason_code="non_learning_distance", learnable=False, ) if any( token in normalized for token in ("werkvorm", "full_time", "part_time", "contract", "freelance", "uren") ): return _LearningSignal( feature="", delta=0.0, reason_code="non_learning_conditions", learnable=False, ) feature = _best_signal_feature(score_run) return _LearningSignal( feature=feature, delta=LEARNING_DELTA_BY_ACTION[Feedback.Action.HIDE], reason_code="explicit_hide", learnable=True, ) def _classify_learning_signal( profile: SearchProfile, job: JobPosting, action: str, reason: str ) -> _LearningSignal | None: score_run = _latest_score_run(profile, job) if action == Feedback.Action.HIDE: return _classify_hide_signal(profile, score_run, reason) if action in (Feedback.Action.INTERESTING, Feedback.Action.SAVE): feature = _best_signal_feature(score_run) return _LearningSignal( feature=feature, delta=LEARNING_DELTA_BY_ACTION[action], reason_code="positive_feedback", learnable=True, ) return None def _learning_signal_count(profile: SearchProfile, feature: str) -> int: if not feature: return 0 count = 0 for metadata in Feedback.objects.filter(profile=profile).values_list("metadata", flat=True): if not isinstance(metadata, dict): continue learning = metadata.get("learning") if isinstance(learning, dict) and learning.get("feature") == feature: count += 1 return count def _apply_learning_metadata( feedback: Feedback, signal: _LearningSignal, *, samples: int, applied: bool ) -> None: metadata: dict[str, Any] = dict(feedback.metadata or {}) metadata["learning"] = { "status": "applied" if applied else "queued", "reason_code": signal.reason_code, "feature": signal.feature, "delta": round(float(signal.delta), 3), "samples": samples, "learnable": signal.learnable, } feedback.metadata = metadata feedback.save(update_fields=["metadata", "updated_at"]) def _evaluate_learning(profile: SearchProfile, feedback: Feedback, signal: _LearningSignal) -> None: if not signal.learnable: _apply_learning_metadata(feedback, signal, samples=0, applied=False) return signal_count = _learning_signal_count(profile, signal.feature) next_count = signal_count + 1 if next_count < LEARNING_MIN_SAMPLES: _apply_learning_metadata(feedback, signal, samples=next_count, applied=False) return if not profile.learning_enabled: _apply_learning_metadata( feedback, _LearningSignal( feature="", delta=0.0, reason_code="learning_disabled", learnable=False, ), samples=next_count, applied=False, ) return apply_feedback_delta(profile=profile, feature=signal.feature, delta=signal.delta) _apply_learning_metadata(feedback, signal, samples=next_count, applied=True) @transaction.atomic def record_feedback( *, user, job: JobPosting, action: str, reason: str = "", ) -> Feedback: profile = SearchProfile.objects.filter(user=user, is_active=True).first() feedback = Feedback.objects.create( user=user, profile=profile, job=job, action=action, reason=reason[:200], ) signal = ( _classify_learning_signal(profile=profile, job=job, action=action, reason=reason) if profile else None ) if signal: _evaluate_learning(profile=profile, feedback=feedback, signal=signal) if action == Feedback.Action.APPLIED: apply_application_on_feedback(user=user, job=job) return feedback