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