Spaces:
Sleeping
Sleeping
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
|