Files
VacatureRadar/apps/jobs/services/feedback.py
T
Jens b8091e59bd
deploy / deploy (push) Canceled after 0s
Initial deploy setup
2026-07-21 14:00:00 +02:00

196 lines
6.0 KiB
Python

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