JAA-ATS-Tool / src /providers.py
saitejatirunagari's picture
Add model-independent LLM provider abstraction with schema validation
9bf4a3d
Raw
History Blame
11.6 kB
"""
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