from __future__ import annotations import json import os import re import time from typing import Any, Callable, TypeVar from pydantic import BaseModel, ValidationError from observability import get_logger, resource_snapshot from prompts import build_repair_json_messages MODEL_ID = "Qwen/Qwen3.5-2B" SchemaT = TypeVar("SchemaT", bound=BaseModel) # A callback reporting generation progress: (tokens_done, tokens_total). StepCallback = Callable[[int, int], None] _processor = None _model = None _execution_cache: str | None = None try: import spaces except ImportError: spaces = None def _is_zerogpu() -> bool: """Whether generation should be wrapped in `spaces.GPU` (no torch import). Decided cheaply so the `@_gpu` decorator can run at import time without pulling in torch. ZeroGPU applies when explicitly requested, or under `auto` on a Hugging Face Space that has the `spaces` runtime. """ mode = os.environ.get("BQ_DEVICE", "auto").strip().lower() if mode == "zerogpu": return True if mode == "auto": return spaces is not None and bool(os.environ.get("SPACE_ID")) return False def resolve_execution() -> str: """Resolve BQ_DEVICE to a concrete mode: zerogpu | cuda | mps | cpu.""" mode = os.environ.get("BQ_DEVICE", "auto").strip().lower() if mode not in {"auto", "zerogpu", "cuda", "mps", "cpu"}: get_logger("model").warning("Unknown BQ_DEVICE=%r; falling back to auto.", mode) mode = "auto" if _is_zerogpu(): return "zerogpu" if mode in {"cuda", "mps", "cpu"}: return mode # auto, not a Space: pick the best local accelerator, else CPU. try: import torch if torch.cuda.is_available(): return "cuda" backend = getattr(torch.backends, "mps", None) if backend is not None and backend.is_available(): return "mps" except Exception: # noqa: BLE001 - torch may be unavailable; CPU is the safe floor pass return "cpu" def execution_mode() -> str: """Cached `resolve_execution()`; safe to call from anywhere, including app.py.""" global _execution_cache if _execution_cache is None: _execution_cache = resolve_execution() return _execution_cache def _gpu(duration: int): if _is_zerogpu() and spaces is not None: return spaces.GPU(duration=duration) return lambda function: function class ModelClientError(RuntimeError): pass def generate_json( messages: list[dict[str, Any]], schema_model: type[SchemaT], schema_name: str, max_new_tokens: int = 8192, on_step: StepCallback | None = None, force_cpu: bool = False, ) -> SchemaT: # ZeroGPU runs generation in a forked subprocess that a live callback cannot # reach, so token progress only applies in-process (local, or a CPU fallback). on_gpu = execution_mode() == "zerogpu" and not force_cpu effective_step = None if on_gpu else on_step raw = generate_text( messages, max_new_tokens=max_new_tokens, on_step=effective_step, force_cpu=force_cpu, json_mode=True, ) cleaned = strip_thinking(raw) try: return _validate_json(cleaned, schema_model) except (json.JSONDecodeError, ValueError, ValidationError) as first_error: repair_messages = build_repair_json_messages( cleaned, schema_name, json.dumps(schema_model.model_json_schema(), ensure_ascii=False, indent=2), str(first_error), ) repaired = strip_thinking( generate_text( repair_messages, max_new_tokens=4096, force_cpu=force_cpu, json_mode=True, ) ) try: return _validate_json(repaired, schema_model) except (json.JSONDecodeError, ValueError, ValidationError) as repair_error: raise ModelClientError(f"Model did not return valid {schema_name} JSON.") from repair_error def generate_text( messages: list[dict[str, Any]], max_new_tokens: int = 8192, on_step: StepCallback | None = None, force_cpu: bool = False, json_mode: bool = False, ) -> str: """Run one generation. On a ZeroGPU Space this dispatches to the GPU worker, which raises a `gradio.Error` when the calling user is out of quota — the app layer catches that and retries with `force_cpu=True`. Every other case (local CUDA/MPS/CPU, or the CPU retry) runs in-process and can stream live token progress. """ if execution_mode() == "zerogpu" and not force_cpu: return _generate_on_gpu(messages, max_new_tokens, json_mode) return _generate_in_process(messages, max_new_tokens, on_step, json_mode) def _generate_in_process( messages: list[dict[str, Any]], max_new_tokens: int, on_step: StepCallback | None = None, json_mode: bool = False, ) -> str: processor, model = load_model() logger = get_logger("generate") inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", enable_thinking=not json_mode, ).to(model.device) input_len = int(inputs["input_ids"].shape[-1]) import torch streamer = _make_progress_streamer(max_new_tokens, on_step) if on_step is not None else None start = time.perf_counter() with torch.inference_mode(): outputs = model.generate( **inputs, max_new_tokens=max_new_tokens, streamer=streamer, # Sampling controls are intentionally left to the model defaults. ) elapsed = time.perf_counter() - start generated = outputs[0][input_len:] output_len = int(generated.shape[-1]) rate = output_len / elapsed if elapsed > 0 else 0.0 logger.info( "generate device=%s in=%d out=%d in %.2fs (%.1f tok/s) | %s", str(model.device), input_len, output_len, elapsed, rate, resource_snapshot(), ) return processor.decode(generated, skip_special_tokens=False) @_gpu(duration=300) def _generate_on_gpu(messages: list[dict[str, Any]], max_new_tokens: int, json_mode: bool) -> str: # Runs inside the ZeroGPU fork: arguments are pickled, so no callbacks here. return _generate_in_process(messages, max_new_tokens, on_step=None, json_mode=json_mode) def _make_progress_streamer(max_new_tokens: int, on_step: StepCallback): """Build a token-counting streamer that reports progress as generation runs. Defined lazily so importing this module never requires transformers. The streamer's `put` is called synchronously by `model.generate` for the prompt (skipped) and then once per generated token; reports are throttled by time. """ from transformers.generation.streamers import BaseStreamer class _ProgressStreamer(BaseStreamer): def __init__(self) -> None: self.total = max(1, max_new_tokens) self.count = 0 self._prompt_seen = False self._last_report = 0.0 self._min_interval = 0.3 def put(self, value) -> None: if not self._prompt_seen: self._prompt_seen = True # first call is the prompt; not generated output return try: self.count += int(value.numel()) except AttributeError: self.count += 1 now = time.perf_counter() if now - self._last_report >= self._min_interval: self._last_report = now self._report() def end(self) -> None: self._report() def _report(self) -> None: try: on_step(min(self.count, self.total), self.total) except Exception: # noqa: BLE001 - progress is best-effort, never fatal pass return _ProgressStreamer() def load_model(): global _processor, _model if _model is not None: return _processor, _model try: from transformers import AutoModelForImageTextToText, AutoProcessor except ImportError as exc: raise ModelClientError( "Missing model dependencies. Install requirements.txt before running Blind Quill." ) from exc import torch logger = get_logger("model") mode = execution_mode() logger.info("Loading %s for execution=%s", MODEL_ID, mode) _processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) if mode in ("zerogpu", "cuda"): # ZeroGPU/CUDA: let accelerate place the model (CUDA-first, as before). _model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, torch_dtype="auto", device_map="auto", trust_remote_code=True, ) else: # Local MPS/CPU: load on CPU then move to the chosen device explicitly. dtype = torch.float16 if mode == "mps" else torch.float32 try: _model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, torch_dtype=dtype, trust_remote_code=True ).to(mode) except Exception as exc: # noqa: BLE001 - MPS half precision can be flaky if mode == "mps": logger.warning("MPS load with float16 failed (%s); retrying with float32.", exc) _model = AutoModelForImageTextToText.from_pretrained( MODEL_ID, torch_dtype=torch.float32, trust_remote_code=True ).to("mps") else: raise logger.info("Model ready execution=%s device=%s", mode, str(_model.device)) return _processor, _model def strip_thinking(text: str) -> str: without_closed_blocks = re.sub(r"]*>.*?", "", text, flags=re.IGNORECASE | re.DOTALL) start_match = re.search(r"]*>", without_closed_blocks, flags=re.IGNORECASE) if start_match: json_start = without_closed_blocks.find("{", start_match.end()) if json_start >= 0: without_closed_blocks = without_closed_blocks[: start_match.start()] + without_closed_blocks[json_start:] else: without_closed_blocks = without_closed_blocks[: start_match.start()] return without_closed_blocks.replace("", "").strip() def extract_json(text: str) -> str: cleaned = strip_thinking(text) decoder = json.JSONDecoder() for index, character in enumerate(cleaned): if character != "{": continue try: _, end = decoder.raw_decode(cleaned[index:]) return cleaned[index : index + end] except json.JSONDecodeError: continue raise ValueError("No JSON object found in model output.") def _validate_json(text: str, schema_model: type[SchemaT]) -> SchemaT: data = json.loads(extract_json(text)) return schema_model.model_validate(data)