206 lines
6.0 KiB
Python
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
|