File size: 7,405 Bytes
c687f2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43c3b77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c687f2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Centralized V1 model fallback with health-checking (NVIDIA NIM / OpenAI-compat).

The configured model can be dead (e.g. z-ai/glm-5.1 → 410 EOL). This module
health-checks a chain of models with a small STRUCTURED task and returns the
first model that actually works end-to-end (HTTP 200, valid JSON, schema-valid,
JD-traceable, no hallucinated phrase, within timeout). If none pass, callers get
`None` and must preserve the résumé and report `live_model_unavailable`.

Never combines partial outputs from different models: a failover re-runs the
whole operation on the replacement model.
"""
from __future__ import annotations

import time
from typing import List, Optional, Tuple

# Ordered preference. Kept centralized so every V1 route shares the same chain.
try:
    from config import V1_MODEL_CHAIN as _CFG_CHAIN
except Exception:
    _CFG_CHAIN = None

DEFAULT_MODEL_CHAIN: List[str] = _CFG_CHAIN or [
    "z-ai/glm-5.2",
    "mistralai/mistral-small-4-119b-2603",
    "nvidia/nemotron-3-super-120b-a12b",
]

# A tiny probe JD with a known exact phrase and a decoy that must NOT be invented.
_PROBE_JD = (
    "About the Role\nWe are hiring a Product Manager.\n"
    "Requirements: strong stakeholder management and SQL. 5+ years experience.\n"
    "Responsibilities: roadmap prioritization and A/B testing."
)
_PROBE_MUST_TRACE = "stakeholder management"


def health_check(model: str, timeout: float = 25.0) -> dict:
    """Probe one model with the real structured-extraction task and validate the
    output the same way production does. Returns a log dict (never raises)."""
    from .llm_client import LLMClient
    from .keyword_schema import validate_and_repair

    log = {
        "requested_model": model, "selected_model": None, "status": "unknown",
        "latency": 0.0, "retry_count": 0, "failure_reason": "",
        "schema_valid": False, "evidence_test_passed": False,
    }
    t0 = time.monotonic()
    try:
        # Rebuild the OpenAI client with a SHORT per-request timeout so a hung
        # model fails fast instead of blocking on the default 90s × retries.
        from openai import OpenAI
        from config import NVIDIA_API_KEY, GLM_BASE_URL
        client = LLMClient(model=model)
        client.client = OpenAI(base_url=GLM_BASE_URL, api_key=NVIDIA_API_KEY,
                               timeout=timeout, max_retries=0)
        sys_p = ("Extract ATS keywords. Return ONLY a JSON array of objects with "
                 "keys exact_phrase, normalized_concept, category, requirement_type, "
                 "importance, source_text, semantic_variants, confidence, "
                 "requires_resume_evidence. No prose.")
        rawtext = client._call(sys_p, f"JD:\n{_PROBE_JD}", max_tokens=1800, retries=2)
        raw = client._extract_json(rawtext)
        log["latency"] = round(time.monotonic() - t0, 2)
        if not isinstance(raw, list) or not raw:
            log["status"] = "empty_or_invalid"
            log["failure_reason"] = "no structured items returned"
            return log
        valid, rejected = validate_and_repair(raw, _PROBE_JD)
        log["schema_valid"] = bool(valid)
        # traceability: the known phrase must be extracted; nothing untraceable kept
        traced = any(_PROBE_MUST_TRACE in (v.get("exact_phrase", "").lower()
                                           + v.get("normalized_concept", "").lower())
                     for v in valid)
        log["evidence_test_passed"] = bool(traced and valid)
        if valid and traced:
            log["status"] = "healthy"
            log["selected_model"] = model
        else:
            log["status"] = "evidence_failure"
            log["failure_reason"] = "probe phrase not traceably extracted"
    except Exception as e:
        log["latency"] = round(time.monotonic() - t0, 2)
        msg = str(e)
        log["failure_reason"] = msg[:160]
        # classify a few common transient/terminal signals
        if "410" in msg:
            log["status"] = "gone_410"
        elif "404" in msg:
            log["status"] = "not_found_404"
        elif "429" in msg:
            log["status"] = "rate_limited_429"
        elif "timeout" in msg.lower():
            log["status"] = "timeout"
        else:
            log["status"] = "error"
    return log


# Module cache: once a model is confirmed healthy, reuse it across requests rather
# than re-probing (which would pay the hung-model timeout every call). If that
# model later fails during real use, callers fall back to deterministic (safe);
# call reset_cache() to force a fresh probe.
_CACHE: dict = {"model": None, "logs": []}


def reset_cache() -> None:
    _CACHE["model"] = None
    _CACHE["logs"] = []


def extraction_gaps(clean_jd: str, valid_items: list) -> list:
    """Deterministic completeness check: obvious source-grounded requirements the
    extraction should have captured but didn't. Returns a list of missed phrases
    (named tools present in the JD, an explicit years-of-experience requirement).
    Used to trigger a correction retry / failover before trusting an extraction."""
    import re
    try:
        from .jd_analyzer import _TOOLS
    except Exception:
        _TOOLS = set()
    low = (clean_jd or "").lower()
    got = " ".join((v.get("exact_phrase", "") + " " + v.get("normalized_concept", ""))
                   for v in (valid_items or [])).lower()
    missed = []
    # named tools/technologies present verbatim in the JD but absent from criteria
    for tool in _TOOLS:
        if re.search(r"(?<![a-z0-9])" + re.escape(tool) + r"(?![a-z0-9])", low) \
                and tool not in got:
            missed.append(tool)
    # explicit years-of-experience requirement (e.g. "10+ years")
    ym = re.search(r"\b(\d{1,2}\+?\s*years?)\b", low)
    if ym and "year" not in got:
        missed.append(ym.group(1))
    return missed[:12]


def select_model(chain: Optional[List[str]] = None,
                 timeout: float = 25.0, use_cache: bool = True) -> Tuple[Optional[str], List[dict]]:
    """Return (first_healthy_model | None, per-model health logs)."""
    if use_cache and _CACHE["model"]:
        return _CACHE["model"], _CACHE["logs"]
    chain = chain or DEFAULT_MODEL_CHAIN
    logs: List[dict] = []
    for model in chain:
        log = health_check(model, timeout=timeout)
        logs.append(log)
        if log["status"] == "healthy":
            if use_cache:
                _CACHE["model"], _CACHE["logs"] = model, logs
            return model, logs
    return None, logs


def build_llm(chain: Optional[List[str]] = None, timeout: float = 25.0,
              use_cache: bool = True):
    """Return (LLMClient bound to a healthy model | None, health logs). None means
    every model failed → caller must preserve résumé + report live_model_unavailable."""
    from .llm_client import LLMClient
    model, logs = select_model(chain, timeout=timeout, use_cache=use_cache)
    if model is None:
        return None, logs
    return LLMClient(model=model), logs


if __name__ == "__main__":  # live health-check (honest: reports real availability)
    import json
    model, logs = select_model()
    print("SELECTED:", model)
    for lg in logs:
        print(json.dumps({k: lg[k] for k in
              ("requested_model", "status", "latency", "schema_valid",
               "evidence_test_passed", "failure_reason")}, ensure_ascii=False))