File size: 11,648 Bytes
9bf4a3d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
"""
LLM provider abstraction (model-independent pipeline).

The rest of the pipeline must not care WHICH model produced the content. Every
provider returns the SAME schema-validated JSON; deterministic validation
(schema + independent score + parse) decides whether output passes β€” never the
model's self-report.

Providers:
  - OpenAICompatProvider  β†’ Claude / Kimi / NVIDIA (all OpenAI-compatible cfgs)
  - StubProvider          β†’ deterministic, schema-valid (tests, offline floor)

Each provider exposes:
  analyze_jd_requirements(jd_text)      -> (data, quality)
  tailor_resume(resume_dict, jd, title, company, assessment) -> (data, quality)
  classify_missing_keywords(missing, jd, resume_text)        -> (data, quality)
  judge_semantic_match(resume_text, keywords)                -> (data, quality)
where quality ∈ {"ok", "failed_schema", "provider_error"}.
"""

from __future__ import annotations

from typing import Tuple, List

try:
    import jsonschema
    _HAVE_JSONSCHEMA = True
except Exception:
    _HAVE_JSONSCHEMA = False

from .llm_client import LLMClient

# ── Schemas (pragmatic β€” strict enough to catch malformed output, lenient
#    enough that compliant models pass on the first try) ──────────────────────

JD_ANALYSIS_SCHEMA = {
    "type": "object",
    "required": ["required_hard_skills"],
    "properties": {
        "target_role_titles": {"type": "array"},
        "required_hard_skills": {"type": "array"},
        "preferred_hard_skills": {"type": "array"},
        "tools_platforms": {"type": "array"},
        "responsibilities": {"type": "array"},
        "domain_terms": {"type": "array"},
        "certifications": {"type": "array"},
        "education_requirements": {"type": "array"},
        "soft_skills": {"type": "array"},
        "seniority_signals": {"type": "array"},
    },
}

RESUME_TAILORING_SCHEMA = {
    "type": "object",
    "required": ["summary", "roles"],
    "properties": {
        "summary": {"type": "string", "minLength": 80},
        "roles": {
            "type": "array",
            "minItems": 1,
            "items": {
                "type": "object",
                "required": ["bullets"],
                "properties": {"bullets": {"type": "array"}},
            },
        },
        "jd_skills": {"type": "array"},
    },
}

MISSING_KEYWORD_REPAIR_SCHEMA = {
    "type": "object",
    "properties": {"decisions": {"type": "array"}},
}

EVIDENCE_MATCH_SCHEMA = {"type": "object"}


def validate_response(data, schema) -> Tuple[bool, str]:
    """Return (ok, error_message)."""
    if not isinstance(data, (dict, list)):
        return (False, "not a JSON object")
    if not _HAVE_JSONSCHEMA:
        # Minimal fallback: required top-level keys present.
        for k in schema.get("required", []):
            if isinstance(data, dict) and k not in data:
                return (False, f"missing required key: {k}")
        return (True, "")
    try:
        jsonschema.validate(instance=data, schema=schema)
        return (True, "")
    except jsonschema.ValidationError as e:
        return (False, str(e.message)[:140])


# ── Provider base ────────────────────────────────────────────────────────────

OK = "ok"
FAILED_SCHEMA = "failed_schema"
PROVIDER_ERROR = "provider_error"


class LLMProvider:
    """Base provider. Subclasses set self.name and self.cfg (or override calls)."""

    name = "base"

    def __init__(self, name: str, cfg: dict, llm: LLMClient = None):
        from .provider_prompts import family_for
        self.name = name
        self.cfg = cfg or {}
        self.llm = llm or LLMClient.__new__(LLMClient)
        self.family = family_for(name or (cfg or {}).get("model", ""))

    # β€” the five capabilities β€”
    def analyze_jd_requirements(self, jd_text: str) -> Tuple[dict, str]:
        try:
            data = self.llm.analyze_jd_requirements(
                self.cfg, jd_text, provider_family=self.family)
        except Exception:
            return ({}, PROVIDER_ERROR)
        if not data:
            return ({}, FAILED_SCHEMA)
        ok, _ = validate_response(data, JD_ANALYSIS_SCHEMA)
        return (data, OK if ok else FAILED_SCHEMA)

    def tailor_resume(self, resume_dict: dict, jd_text: str, job_title: str,
                      company: str, assessment: dict) -> Tuple[dict, str]:
        try:
            data = self.llm.tailor_resume_v4(
                self.cfg, resume_dict, jd_text, job_title, company, assessment,
                provider_family=self.family)
        except Exception:
            return (resume_dict, PROVIDER_ERROR)
        ok, _ = validate_response(data, RESUME_TAILORING_SCHEMA)
        if ok:
            return (data, OK)
        # Schema failed β†’ return input unchanged so the deterministic backfill
        # still produces SOMETHING, but flag it so it can't be marked READY.
        return (data if isinstance(data, dict) else resume_dict, FAILED_SCHEMA)

    def repair_resume(self, resume_dict: dict, jd_text: str, job_title: str,
                      company: str, missing_terms: List[str]) -> Tuple[dict, str]:
        try:
            data = self.llm.repair_resume_v4(
                self.cfg, resume_dict, jd_text, job_title, company,
                missing_terms, provider_family=self.family)
        except Exception:
            return (resume_dict, PROVIDER_ERROR)
        ok, _ = validate_response(data, RESUME_TAILORING_SCHEMA)
        return (data if isinstance(data, dict) else resume_dict,
                OK if ok else FAILED_SCHEMA)

    def jobalytics_repair(self, resume_dict: dict, jd_text: str, job_title: str,
                          company: str, missing_keywords: List[str],
                          placement_guidance: str) -> Tuple[dict, str]:
        try:
            data = self.llm.jobalytics_repair_v4(
                self.cfg, resume_dict, jd_text, job_title, company,
                missing_keywords, placement_guidance, provider_family=self.family)
        except Exception:
            return (resume_dict, PROVIDER_ERROR)
        ok, _ = validate_response(data, RESUME_TAILORING_SCHEMA)
        return (data if isinstance(data, dict) else resume_dict,
                OK if ok else FAILED_SCHEMA)

    def classify_missing_keywords(self, missing: List[str], jd_text: str,
                                  resume_text: str) -> Tuple[dict, str]:
        # Deterministic β€” reuses the evidence/fit classification, model-agnostic.
        from .ats_report import reconcile_missing_keywords
        try:
            rows = reconcile_missing_keywords(missing, jd_text, resume_text)
            return ({"decisions": rows}, OK)
        except Exception:
            return ({"decisions": []}, PROVIDER_ERROR)

    def judge_semantic_match(self, resume_text: str,
                             keywords: List[str]) -> Tuple[dict, str]:
        try:
            data = self.llm.judge_evidence(self.cfg, resume_text, keywords)
            return (data or {}, OK)
        except Exception:
            return ({}, PROVIDER_ERROR)


class OpenAICompatProvider(LLMProvider):
    """Claude / Kimi / NVIDIA β€” all OpenAI-compatible chat endpoints driven by a
    cfg dict (model, base_url, api_key, extra_body). Differences are confined to
    cfg + provider-specific prompt style (see prompts/)."""
    pass


class StubProvider(LLMProvider):
    """Deterministic, schema-valid output. No network. Used for tests and as the
    offline 'deterministic' floor in the provider chain."""

    def __init__(self):
        super().__init__("stub", {"model": "stub"}, llm=None)

    def analyze_jd_requirements(self, jd_text: str) -> Tuple[dict, str]:
        # Empty β†’ the deterministic gazetteer analyzer fills everything in.
        return ({}, OK)

    def tailor_resume(self, resume_dict, jd_text, job_title, company, assessment):
        out = dict(resume_dict)
        out["summary"] = (f"Strong-fit candidate for {job_title} at {company}: "
                          + (resume_dict.get("summary") or "PM with 5+ years."))
        ok, _ = validate_response(out, RESUME_TAILORING_SCHEMA)
        return (out, OK if ok else FAILED_SCHEMA)

    def repair_resume(self, resume_dict, jd_text, job_title, company, missing_terms):
        # Deterministic no-op repair β€” the customizer's deterministic weaver does
        # the real work; the stub just returns valid input.
        return (dict(resume_dict), OK)

    def jobalytics_repair(self, resume_dict, jd_text, job_title, company,
                          missing_keywords, placement_guidance):
        return (dict(resume_dict), OK)


# ── Provider chain factory ────────────────────────────────────────────────────

def _resolve_alias(name: str, models: list) -> dict:
    """Resolve a provider_order token to a model cfg dict (or None).

    Supported tokens:
      kimi            β†’ first model whose name/model contains 'kimi'
      claude          β†’ first model whose name/model contains 'claude'
      nvidia_primary  β†’ first tailor-capable NVIDIA model (not kimi/claude)
      nvidia_backup   β†’ second tailor-capable NVIDIA model
      <exact name>    β†’ exact (case-insensitive) ASSESSMENT_MODELS name match
    """
    key = name.lower()

    def _contains(sub):
        return next((m for m in models
                     if sub in (m.get("name", "") + " " + m.get("model", "")).lower()
                     and m.get("api_key")), None)

    if key == "kimi":
        return _contains("kimi")
    if key == "claude":
        return _contains("claude")
    if key in ("nvidia_primary", "nvidia_backup"):
        tailor_nv = [m for m in models
                     if m.get("tailor") and m.get("api_key")
                     and "kimi" not in (m.get("name", "") + m.get("model", "")).lower()
                     and "claude" not in (m.get("name", "") + m.get("model", "")).lower()]
        idx = 0 if key == "nvidia_primary" else 1
        return tailor_nv[idx] if len(tailor_nv) > idx else None
    # exact name match
    return next((m for m in models
                 if m.get("name", "").lower() == key and m.get("api_key")), None)


def build_provider_chain(llm: LLMClient = None) -> List[LLMProvider]:
    """Build the ordered provider chain from config.LLM_GENERATION.provider_order,
    resolving names against config.ASSESSMENT_MODELS. Unknown/unavailable names are
    skipped; 'deterministic'/'stub' appends the StubProvider as the always-available
    floor. Duplicate models are de-duplicated so the chain has distinct providers.
    """
    import config
    order = (getattr(config, "LLM_GENERATION", {}) or {}).get(
        "provider_order", ["deterministic"])
    models = list(config.ASSESSMENT_MODELS)
    llm = llm or LLMClient.__new__(LLMClient)
    chain: List[LLMProvider] = []
    seen_models = set()
    for nm in order:
        key = (nm or "").lower()
        if key in ("deterministic", "stub"):
            if "stub" not in seen_models:
                chain.append(StubProvider())
                seen_models.add("stub")
            continue
        cfg = _resolve_alias(key, models)
        if cfg and cfg.get("api_key") and cfg.get("model") not in seen_models:
            chain.append(OpenAICompatProvider(cfg.get("name", nm), cfg, llm))
            seen_models.add(cfg.get("model"))
    if not chain:
        chain.append(StubProvider())
    return chain