"""Local GGUF LLM client (llama.cpp). Lazy-loaded + cached; no network at generate time.
``llama_cpp`` is imported INSIDE the loader (not at module top) so importing this module — and running the
gates — needs no compiled backend or downloaded weights. Models cache under ``data/artifacts/models/`` via
an ``HF_HOME`` redirect, so once fetched the run is fully offline.
"""
from __future__ import annotations
import os
import re
import threading
import time
from functools import lru_cache
from typing import TYPE_CHECKING, Any
from common.logging import get_logger
from common.paths import REPO_ROOT
if TYPE_CHECKING:
from config import Settings
MODEL_DIR = REPO_ROOT / "data" / "artifacts" / "models"
def cached_hf_download(repo_id: str, filename: str) -> str:
"""Resolve ``filename`` from ``repo_id``: local cache first (zero network), download only if missing.
Plain ``hf_hub_download`` fires a live HEAD request on every call — even for a fully-cached file — to
validate the etag, which is a real network attempt the "no network during ranking" contract shouldn't
have to tolerate. ``try_to_load_from_cache`` gives a tri-state local-only answer (found / confirmed
absent via a ``.no_exist`` marker from a prior check / never checked) — only the last state should ever
reach the network, otherwise a known-missing optional file (e.g. a model without a sibling
``.onnx_data``) would retry the Hub on every single load forever."""
from huggingface_hub import _CACHED_NO_EXIST, hf_hub_download, try_to_load_from_cache
cached = try_to_load_from_cache(repo_id, filename)
if isinstance(cached, str):
return cached
if cached is _CACHED_NO_EXIST:
raise FileNotFoundError(f"{filename!r} confirmed absent from {repo_id!r} (cached .no_exist marker)")
return hf_hub_download(repo_id=repo_id, filename=filename) # never checked locally — genuinely need network
# Qwen3 is a reasoning model — it emits … before its answer. We never want that prose in the
# extracted spec / HyDE, so we both ask it to skip thinking (/no_think soft switch) and strip any residual
# think block (incl. a truncated, unclosed one) from the output.
_THINK_BLOCK = re.compile(r".*?", re.DOTALL)
_THINK_DANGLING = re.compile(r".*$", re.DOTALL)
def _strip_thinking(text: str) -> str:
"""Remove a Qwen3 block (closed or truncated) and surrounding whitespace."""
return _THINK_DANGLING.sub("", _THINK_BLOCK.sub("", text)).strip()
_HEARTBEAT_SECONDS = 5.0
def _generate_streaming(model: Any, messages: list, temperature: float, top_p: float, max_tokens: int, log: Any) -> str:
"""Run ``create_chat_completion(stream=True)`` and log a heartbeat every ``_HEARTBEAT_SECONDS`` — gives
visibility into whether a long call is actively progressing (chars accumulating) or genuinely stuck
(heartbeats stop appearing), which a single before/after log pair can't distinguish. Used for EVERY call,
not just timed ones — JD parse has no timeout by design, so this is the only way to tell "slow but working"
from "hung" without cutting anything off."""
chunks: list[str] = []
start = time.perf_counter()
last_heartbeat = start
for chunk in model.create_chat_completion(
messages=messages, temperature=temperature, top_p=top_p, max_tokens=max_tokens, stream=True
):
piece = chunk["choices"][0].get("delta", {}).get("content") or ""
if piece:
chunks.append(piece)
now = time.perf_counter()
if now - last_heartbeat >= _HEARTBEAT_SECONDS:
log.info("llm.generate_heartbeat", elapsed=round(now - start, 1), chars_so_far=sum(len(c) for c in chunks))
last_heartbeat = now
return "".join(chunks)
def _run_with_timeout(fn: Any, timeout: float) -> Any:
"""Run ``fn()`` on a daemon thread and wait up to ``timeout`` seconds; raise ``TimeoutError`` if it's still
running. llama.cpp offers no cooperative cancellation mid-generation, so a genuinely stuck/runaway call
can't be killed — the thread is simply abandoned (daemon=True so it can't block process exit) and the
caller moves on. This exists because a single generation was observed taking 1996s (33 min) on real
deployment hardware with NOTHING to stop it — the between-call budget check in tiers.py can't help since
it only gates the NEXT call, not one already in flight."""
result: dict[str, Any] = {}
def target() -> None:
try:
result["value"] = fn()
except Exception as exc: # noqa: BLE001 — re-raised on the calling thread below
result["error"] = exc
thread = threading.Thread(target=target, daemon=True)
thread.start()
thread.join(timeout)
if thread.is_alive():
raise TimeoutError(f"generation exceeded {timeout}s — abandoned (still running in the background)")
if "error" in result:
raise result["error"]
return result["value"]
@lru_cache(maxsize=2)
def _load_llama(repo: str, gguf: str, n_ctx: int, n_threads: int) -> Any:
"""Load a GGUF model into a cached llama.cpp handle.
An EXACT ``gguf`` filename is resolved with ``cached_hf_download`` (cache-only lookup first, no Hub API
call once baked — falls back to a real fetch only if missing). ``Llama.from_pretrained`` is NOT used for
exact names because it always lists the repo over the network. A glob ``gguf`` (the rare fallback model)
still routes through ``from_pretrained`` (needs network)."""
from llama_cpp import Llama # imported lazily — keeps the import graph backend-free
MODEL_DIR.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("HF_HOME", str(MODEL_DIR)) # cache weights inside the repo, not the user home
log = get_logger("models")
log.info("llm.load", repo=repo, gguf=gguf, n_ctx=n_ctx, n_threads=n_threads)
start = time.perf_counter()
try:
if "*" in gguf or "?" in gguf: # glob → must list the repo (online only); used by the SmolLM2 fallback
model = Llama.from_pretrained(repo_id=repo, filename=gguf, n_ctx=n_ctx,
n_threads=(n_threads or None), verbose=False)
else:
model_path = cached_hf_download(repo, gguf) # local cache first; downloads only if genuinely missing
model = Llama(model_path=model_path, n_ctx=n_ctx, n_threads=(n_threads or None), verbose=False)
except Exception as exc:
log.error("llm.load_failed", repo=repo, gguf=gguf, seconds=round(time.perf_counter() - start, 3), error=str(exc))
raise
log.info("llm.load_complete", repo=repo, seconds=round(time.perf_counter() - start, 3))
return model
class LlmClient:
"""Thin wrapper over a cached GGUF model exposing a single ``generate`` call."""
def __init__(self, repo: str, gguf: str, n_ctx: int = 4096, n_threads: int = 0) -> None:
self._repo, self._gguf, self._n_ctx, self._n_threads = repo, gguf, n_ctx, n_threads
# ``/no_think`` is a Qwen3-only soft switch; appending it to other models (LFM2.5, etc.) would inject
# literal junk into the prompt. Gate it on the repo so each model gets the right convention.
self._soft_no_think = "qwen" in repo.lower()
def ensure(self) -> None:
"""Force the (down)load now — used by setup so the rank/JD run doesn't pay it on the clock."""
_load_llama(self._repo, self._gguf, self._n_ctx, self._n_threads)
def generate(
self, prompt: str, system: str | None = None,
temperature: float = 0.0, top_p: float = 0.95, max_tokens: int = 512, think: bool = False,
timeout: float | None = None,
) -> str:
"""Chat-complete ``prompt`` (temperature=0 ⇒ greedy). Reasoning suppressed + stripped by default.
Always streams internally with a periodic heartbeat log (``llm.generate_heartbeat`` every ~5s) — for a
call with no ``timeout`` (JD parse, by design), that heartbeat is the ONLY way to tell "slow but still
producing tokens" from "genuinely hung" during a long silence; without it there's nothing between
``generate_start`` and either ``generate_complete`` or nothing at all.
``timeout``, if given, is a hard wall-clock cap enforced via ``_run_with_timeout`` — a real generation
was observed taking 1996s with nothing to stop it, so callers on a wall-clock budget (Step 11 polish)
should always pass one; JD parse intentionally does NOT (its accuracy matters more than its wall-clock).
"""
model = _load_llama(self._repo, self._gguf, self._n_ctx, self._n_threads)
# Qwen3: append the /no_think soft switch to skip chain-of-thought. Non-Qwen models: send as-is.
user = prompt + " /no_think" if self._soft_no_think and not think else prompt
messages = ([{"role": "system", "content": system}] if system else []) + [
{"role": "user", "content": user}
]
log = get_logger("models")
log.info("llm.generate_start", prompt_chars=len(user), max_tokens=max_tokens, timeout=timeout)
start = time.perf_counter()
call = lambda: _generate_streaming(model, messages, temperature, top_p, max_tokens, log) # noqa: E731
try:
raw_text = _run_with_timeout(call, timeout) if timeout else call()
except Exception as exc:
log.error("llm.generate_failed", seconds=round(time.perf_counter() - start, 3), error=str(exc))
raise
text = _strip_thinking(raw_text)
log.info("llm.generate_complete", seconds=round(time.perf_counter() - start, 3), chars_out=len(text))
return text
def index_gen_client(settings: Settings) -> LlmClient:
"""The JD-parse LLM client (Qwen3-1.7B GGUF, standard instruct) built from central config.
``models.n_threads`` is llama.cpp's OWN explicit override; when it's left at its default (0), the shared
``compute.cpu_threads`` budget applies instead, so one config knob caps the whole engine consistently.
"""
from common.runtime import resolve_cpu_threads
models = settings.models
threads = models.n_threads or resolve_cpu_threads(settings.compute.cpu_threads)
return LlmClient(models.index_gen_llm_repo, models.index_gen_llm_gguf, models.n_ctx, threads)