from __future__ import annotations from dataclasses import dataclass from datetime import timedelta from typing import Any from django.db import transaction from django.utils import timezone from apps.jobs.models import Application, 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: 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