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