File size: 6,737 Bytes
5c2e981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
175
176
177
178
179
180
181
182
183
"""
IBM Granite 4.1 3B Instruct model loader and generation utilities.
"""

from __future__ import annotations

import os
from typing import Optional

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline, Pipeline

from config import cfg
from logging_config import get_logger, setup_logging
from utils import Timer

setup_logging(log_dir=cfg.app.log_dir)
logger = get_logger(__name__)


class GraniteModelLoader:
    """
    Loads IBM Granite 4.1 3B Instruct and exposes a text-generation pipeline.
    """

    def __init__(self) -> None:
        self.pipe: Optional[Pipeline] = None
        self.tokenizer = None
        self.model_id = cfg.model.model_id
        self._is_loaded = False

    # ── Loading ───────────────────────────────────────────────────────────────

    def load(self, token: Optional[str] = None) -> None:
        if self._is_loaded:
            logger.info("Model already loaded; skipping.")
            return

        hf_token = token or cfg.hf_token
        if not hf_token:
            raise EnvironmentError(
                "Hugging Face token is required to load gated models. "
                "Set the HF_TOKEN environment variable."
            )

        logger.info("Loading tokenizer for '%s'…", self.model_id)
        with Timer() as t:
            self.tokenizer = AutoTokenizer.from_pretrained(
                self.model_id,
                token=hf_token,
            )
        logger.info("Tokenizer loaded in %s.", t)

        dtype = self._resolve_dtype()
        logger.info("Loading model with dtype=%s…", dtype)
        with Timer() as t:
            model = AutoModelForCausalLM.from_pretrained(
                self.model_id,
                token=hf_token,
                torch_dtype=dtype,
                device_map=cfg.model.device_map,
                low_cpu_mem_usage=True,
            )
        logger.info("Model loaded in %s.", t)

        self.pipe = pipeline(
            "text-generation",
            model=model,
            tokenizer=self.tokenizer,
            return_full_text=False,
        )
        self._is_loaded = True
        logger.info("Generation pipeline ready.")

    # ── Generation ────────────────────────────────────────────────────────────

    def generate(
        self,
        prompt: str,
        max_new_tokens: Optional[int] = None,
        temperature: Optional[float] = None,
        top_p: Optional[float] = None,
        repetition_penalty: Optional[float] = None,
    ) -> str:
        if not self._is_loaded or self.pipe is None:
            raise RuntimeError("Model is not loaded. Call load() first.")

        params = {
            "max_new_tokens":    max_new_tokens    or cfg.model.max_new_tokens,
            "temperature":       temperature       or cfg.model.temperature,
            "top_p":             top_p             or cfg.model.top_p,
            "repetition_penalty": repetition_penalty or cfg.model.repetition_penalty,
            "do_sample":         cfg.model.do_sample,
        }

        logger.info(
            "Generating response (max_new_tokens=%d, temperature=%.2f)…",
            params["max_new_tokens"],
            params["temperature"],
        )

        with Timer() as t:
            output = self.pipe(prompt, **params)

        text = output[0]["generated_text"].strip()
        logger.info("Response generated in %s (%d chars).", t, len(text))
        return text

    # ── Prompt construction ───────────────────────────────────────────────────

    @staticmethod
    def build_prompt(
        query: str,
        retrieved_context: str,
        conversation_history: str = "",
    ) -> str:
        """
        Builds a retrieval-first prompt for IBM Granite chat models.
        Uses the model's expected <|...|> chat tokens.
        """
        system_msg = (
            "You are a professional university admissions assistant. "
            "Your role is to help prospective students with accurate information "
            "about admissions requirements, programs, fees, scholarships, deadlines, "
            "and related topics.\n\n"
            "STRICT RULES:\n"
            "1. Answer ONLY using the information provided in the CONTEXT section below.\n"
            "2. If the answer is not present in the context, respond: "
            "'I'm sorry, I couldn't find that information in the university knowledge base. "
            "Please contact the admissions office directly for assistance.'\n"
            "3. NEVER invent, guess, or extrapolate admissions policies, fees, or deadlines.\n"
            "4. Cite the source document name when possible.\n"
            "5. Keep answers concise, accurate, and professionally toned.\n"
            "6. If the query is off-topic (not related to university admissions), "
            "politely redirect the user."
        )

        context_block = (
            f"CONTEXT (retrieved from university knowledge base):\n"
            f"{'=' * 60}\n"
            f"{retrieved_context}\n"
            f"{'=' * 60}"
        )

        history_block = ""
        if conversation_history:
            history_block = (
                f"\nCONVERSATION HISTORY:\n{conversation_history}\n"
            )

        user_content = (
            f"{context_block}\n"
            f"{history_block}\n"
            f"QUESTION: {query}"
        )

        # IBM Granite 4.1 uses the standard HuggingFace chat template;
        # we format manually for pipeline compatibility.
        prompt = (
            f"<|start_of_role|>system<|end_of_role|>{system_msg}<|end_of_text|>\n"
            f"<|start_of_role|>user<|end_of_role|>{user_content}<|end_of_text|>\n"
            f"<|start_of_role|>assistant<|end_of_role|>"
        )
        return prompt

    # ── Helpers ───────────────────────────────────────────────────────────────

    @staticmethod
    def _resolve_dtype() -> torch.dtype:
        if torch.cuda.is_available():
            return torch.bfloat16
        if torch.backends.mps.is_available():
            return torch.float16
        return torch.float32

    @property
    def is_loaded(self) -> bool:
        return self._is_loaded

    @property
    def model_name(self) -> str:
        return self.model_id