File size: 5,066 Bytes
dd47e55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5dd879
 
 
dd47e55
 
 
 
 
b3704a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd47e55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
"""
90% feasibility gate + status system (spec items #2-gate, #3-status).

Before generating, estimate whether the candidate can realistically reach a
90%+ JD match AFTER aggressive plausible expansion β€” and decide how to proceed.

Status lifecycle for any generated resume:
  READY_90_PLUS                  β€” JD match >= 90 and ATS readability >= 90
  READY_90_PLUS_REVIEW_RECOMMENDED β€” reached 90 but used risky/review terms
  NEEDS_REPAIR                   β€” below 90, still in the repair loop
  NEEDS_USER_INPUT               β€” below 90; needs user-confirmed credentials/terms
  NOT_ELIGIBLE_LOW_FIT           β€” JD clearly unrelated to candidate background
  PARSE_FAILED                   β€” exported file failed parse validation
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import List

from .jd_analyzer import analyze_jd
from .candidate_fit import (
    classify_all_fit, includable, review_terms, ask_user_terms, blocked_terms,
)
from .ats_scoring_v2 import score_jd_match, score_ats_readability

# Status constants
READY = "READY_90_PLUS"
READY_REVIEW = "READY_90_PLUS_REVIEW_RECOMMENDED"
# External-feedback repair only: internal + independent >= 90 AND the pasted
# external-checker gaps were mostly resolved (no fabrication).
READY_95_EXTERNAL_ALIGNED = "READY_95_EXTERNAL_ALIGNED"
NEEDS_REPAIR = "NEEDS_REPAIR"
NEEDS_USER_INPUT = "NEEDS_USER_INPUT"
LOW_FIT = "NOT_ELIGIBLE_LOW_FIT"
PARSE_FAILED = "PARSE_FAILED"

# ── Maximum ATS Mode statuses (User-Confirmed Skill Expansion) ───────────────
# READY_MAX_ATS_95_PLUS β€” internal + independent + readability pass AND
#   external-style coverage >= 95% (or pasted external score >= 95).
READY_MAX_ATS_95_PLUS = "READY_MAX_ATS_95_PLUS"
# READY_90_PLUS_EXTERNAL_ALIGNED β€” the same gates pass AND coverage >= 90%.
READY_90_PLUS_EXTERNAL_ALIGNED = "READY_90_PLUS_EXTERNAL_ALIGNED"
# NEEDS_USER_CONFIRMATION β€” remaining gaps are HIGH-risk-but-supportable terms
#   the user can confirm with one click (then we regenerate).
NEEDS_USER_CONFIRMATION = "NEEDS_USER_CONFIRMATION"
# BELOW_TARGET_REPAIRABLE β€” below target but remaining gaps are LOW/MEDIUM, so
#   the system should keep repairing rather than stop.
BELOW_TARGET_REPAIRABLE = "BELOW_TARGET_REPAIRABLE"

# Statuses that represent a downloadable, target-meeting result.
MAX_ATS_READY_STATUSES = frozenset({
    READY, READY_REVIEW, READY_95_EXTERNAL_ALIGNED,
    READY_MAX_ATS_95_PLUS, READY_90_PLUS_EXTERNAL_ALIGNED,
})


@dataclass
class FitAssessment:
    eligible_for_auto_resume: bool
    estimated_max_score: int
    estimated_max_with_review: int
    explicit_matches: List[str] = field(default_factory=list)
    plausible_matches: List[str] = field(default_factory=list)
    adjacent_matches: List[str] = field(default_factory=list)
    risky_matches: List[str] = field(default_factory=list)
    blocked_matches: List[str] = field(default_factory=list)
    recommendation: str = "generate"   # generate|generate_with_review|ask_user|skip

    def to_dict(self):
        return self.__dict__


def _synthetic_text(base_text: str, terms: List[str]) -> str:
    """A best-case resume text = base + the terms we'd include, for estimating
    the achievable JD-match ceiling."""
    return base_text + "\n" + " . ".join(terms)


def assess_job_fit_for_90(jd_text: str, base_resume_text: str,
                          llm=None, cfg: dict = None,
                          mode: str = "aggressive_plausible_match") -> FitAssessment:
    req = analyze_jd(jd_text, llm=llm, cfg=cfg)
    verdicts = classify_all_fit(req, base_resume_text)

    inc = [v.keyword for v in includable(verdicts)]
    rev = [v.keyword for v in review_terms(verdicts)]
    ask = [v.keyword for v in ask_user_terms(verdicts)]
    blk = [v.keyword for v in blocked_terms(verdicts)]

    by_status = lambda s: [v.keyword for v in verdicts if v.fit_status == s]

    # Estimate ceilings: score a synthetic resume containing the include set,
    # and another that also adds the risky/review set.
    est_inc = score_jd_match(_synthetic_text(base_resume_text, inc), req).score
    est_rev = score_jd_match(_synthetic_text(base_resume_text, inc + rev), req).score

    # Recommendation
    if est_inc >= 90:
        rec, eligible = "generate", True
    elif est_rev >= 90:
        rec, eligible = "generate_with_review", True
    elif ask and est_rev >= 80:
        rec, eligible = "ask_user", False
    elif est_rev >= 70:
        # Worth generating, will likely land in NEEDS_REPAIR/REVIEW
        rec, eligible = "generate_with_review", True
    else:
        rec, eligible = "skip", False

    return FitAssessment(
        eligible_for_auto_resume=eligible,
        estimated_max_score=est_inc,
        estimated_max_with_review=est_rev,
        explicit_matches=by_status("explicit"),
        plausible_matches=by_status("plausible"),
        adjacent_matches=by_status("adjacent"),
        risky_matches=rev,
        blocked_matches=blk,
        recommendation=rec,
    )