File size: 8,109 Bytes
4a4df15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Dual-model inference. Construct the backend at application module scope, before launch."""
from __future__ import annotations

import json
import os
import re
import time
from .config import SETTINGS
from . import prompts
from .validation import json_object, OutputError


_ACTIVE_BACKEND = None
_GPU_TASK = None


def _gpu_duration(task: str, payload_json: str) -> int:
    return {"question": SETTINGS.question_seconds, "grade": SETTINGS.grading_seconds,
            "explain": SETTINGS.hint_seconds}[task]


def _gpu_entry(task: str, payload_json: str) -> str:
    # Module-level callable, as in the original working ZeroGPU pattern.
    if _ACTIVE_BACKEND is None:
        raise RuntimeError("Inference backend was not initialized at startup.")
    return _ACTIVE_BACKEND._dispatch(task, payload_json)


class TransformersBackend:
    def __init__(self):
        # Must precede torch/transformers (app.py imports spaces before Gradio too).
        import spaces
        import torch
        from transformers import AutoModelForCausalLM, AutoTokenizer
        self.torch = torch
        self.models = {}
        self.device = os.getenv("LLM_DEVICE", "cuda")
        if self.device == "cuda" and torch.version.cuda is None:
            raise RuntimeError("A CUDA-enabled PyTorch runtime is required. Use DEMO_MODE=1 for CPU-only UI tests.")
        dtype = torch.bfloat16 if self.device == "cuda" else torch.float32
        for name in dict.fromkeys([SETTINGS.question_model, SETTINGS.grading_model]):
            tokenizer = AutoTokenizer.from_pretrained(name, trust_remote_code=False)
            model = AutoModelForCausalLM.from_pretrained(name, torch_dtype=dtype,
                                                       low_cpu_mem_usage=True, trust_remote_code=False)
            model.to(self.device)
            model.eval()
            self.models[name] = (model, tokenizer)
        print(f"[Models] question={SETTINGS.question_model}; evaluator={SETTINGS.grading_model}; device={self.device}", flush=True)

        global _ACTIVE_BACKEND, _GPU_TASK
        _ACTIVE_BACKEND = self
        _GPU_TASK = spaces.GPU(duration=_gpu_duration, size="large")(_gpu_entry)

    def _dispatch(self, task: str, payload_json: str) -> str:
        print(f"[Inference worker] task={task} device={self.device}", flush=True)
        payload = json.loads(payload_json)
        if task == "question":
            draft = self._generate(prompts.QUESTION, payload, SETTINGS.question_model, 1600)
            from .schemas import Chunk
            from .validation import validate_draft, apply_audit
            source_objects = [Chunk(**c) for c in payload["sources"]]
            parsed = validate_draft(draft, source_objects)
            audit = self._generate(prompts.AUDIT,
                                   {"sources": payload["sources"], "candidate": parsed.model_dump()},
                                   SETTINGS.question_model, 420)
            final = apply_audit(parsed, audit)
            print(f"[Rubric audit] draft_items={len(parsed.concepts)} final_items={len(final.concepts)}", flush=True)
            return final.model_dump_json()
        if task == "grade":
            return json.dumps(self._generate(prompts.ASSESS, payload, SETTINGS.grading_model, 1800))
        if task == "explain":
            return json.dumps(self._generate(prompts.EXPLAIN, payload, SETTINGS.question_model, 650))
        raise ValueError("Unknown task.")

    def _generate(self, system: str, data: dict, model_id: str, new_tokens: int) -> dict:
        model, tokenizer = self.models[model_id]
        text = tokenizer.apply_chat_template([{"role": "system", "content": system},
                                               {"role": "user", "content": json.dumps(data, ensure_ascii=False)}],
                                              tokenize=False, add_generation_prompt=True, enable_thinking=False)
        inputs = tokenizer(text, return_tensors="pt")
        count = int(inputs.input_ids.shape[1])
        if count > SETTINGS.max_prompt_tokens:
            raise OutputError("This question has too much context. Choose a narrower topic or shorten the answer.")
        inputs = inputs.to(self.device)
        print(f"[LLM] model={model_id} input_tokens={count} output_limit={new_tokens}", flush=True)
        started = time.monotonic()
        with self.torch.inference_mode():
            output = model.generate(**inputs, max_new_tokens=new_tokens, do_sample=False,
                                    pad_token_id=tokenizer.eos_token_id, use_cache=True)
        generated = output[0, count:]
        raw = tokenizer.decode(generated, skip_special_tokens=True)
        print(f"[LLM] generated_tokens={len(generated)} seconds={time.monotonic()-started:.1f}", flush=True)
        # Decode and validate in the GPU worker; only plain JSON crosses back.
        return json_object(raw)

    def call(self, task: str, payload: dict) -> dict:
        return json.loads(_GPU_TASK(task, json.dumps(payload, ensure_ascii=False)))


class DemoBackend:
    """Deterministic fixtures for local UI/testing, prominently labelled. Not a tutor."""
    def call(self, task: str, payload: dict) -> dict:
        if task == "question":
            sources = payload["sources"][:2]
            aspects = ["main concepts"]
            concepts = []
            for i, c in enumerate(sources, 1):
                quote = c["text"][:200].strip()
                concepts.append({"id": f"C{i}", "name": f"Source concept {i}",
                                 "description": quote, "importance": "essential" if i == 1 else "important",
                                 "aspect": aspects[0], "relevance": "The question asks for the main concepts in these passages.",
                                 "evidence": [{"source_id": c["id"], "quote": quote}]})
            return {"question": f"Describe the main concepts in {payload['topic_label']}.",
                    "asked_aspects": aspects, "concepts": concepts}
        if task == "grade":
            checks = []
            answer = payload["answer"]
            for c in payload["rubric"]["concepts"]:
                covered = c["description"].casefold() in answer.casefold()
                checks.append({"concept_id": c["id"], "status": "covered" if covered else "missing",
                               "answer_evidence": c["description"] if covered else "",
                               "missing_detail": "Demo fixture: this source phrase was not reproduced.",
                               "what_to_add": c["description"]})
            return {"checks": checks}
        return {"notes": []}


def create_backend():
    return DemoBackend() if SETTINGS.demo else TransformersBackend()


class LocalCPUBackend(TransformersBackend):
    """Same constrained task pipeline as ZeroGPU, with a CPU model cache.

    Constructed in a fresh CPU subprocess, not in the hosted ZeroGPU parent.
    Only one model is kept resident at a time to bound memory use.
    """
    def __init__(self):
        import torch
        self.torch = torch
        self.device = 'cpu'
        self.models = {}
        torch.set_num_threads(int(os.getenv('LOCAL_CPU_THREADS', '4')))

    def _generate(self, system, data, model_id, new_tokens):
        if model_id not in self.models:
            import gc
            self.models.clear()
            gc.collect()
            from transformers import AutoModelForCausalLM, AutoTokenizer
            print(f"[CPU LLM] loading {model_id}; no remote inference", flush=True)
            offline = os.getenv('HF_HUB_OFFLINE', '0') == '1'
            tokenizer = AutoTokenizer.from_pretrained(model_id, trust_remote_code=False, token=False, local_files_only=offline)
            model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype=self.torch.float32,
                low_cpu_mem_usage=True, trust_remote_code=False, token=False, local_files_only=offline)
            model.to('cpu')
            model.eval()
            self.models[model_id] = (model, tokenizer)
        return super()._generate(system, data, model_id, new_tokens)