Files
VacatureRadar/apps/jobs/services/feedback.py
T

206 lines
6.0 KiB
Python

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",
"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