File size: 10,316 Bytes
c120125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# main.py β€” Timetable SLM API (HuggingFace Spaces deployment)

import os, re, json, time
from typing import List
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

# ── Globals ───────────────────────────────────────────────────────────────
MODEL     = None
TOKENIZER = None
DEVICE    = None

# These are set as HuggingFace Space Secrets
HF_REPO   = os.environ.get('HF_REPO', 'vishwasmsme/timetable-slm')
HF_TOKEN  = os.environ.get('HF_TOKEN', '')
MODEL_DIR = os.environ.get('MODEL_DIR', '/tmp/timetable_slm_model')

MAX_INPUT     = 96
MAX_OUTPUT    = 200
PROMPT_PREFIX = (
    'Convert the following timetable instruction into a structured JSON constraint. '
    'Only return valid JSON, no explanation.\n\nInstruction: '
)

CONSTRAINT_SCHEMA = {
    'FACULTY_UNAVAILABLE':        {'faculty_id':None,'days':None,'slots':None},
    'FACULTY_PREFERRED_TIME':     {'faculty_id':None,'period':None,'days':None,'priority':'SOFT'},
    'FACULTY_MAX_DAILY_HOURS':    {'faculty_id':None,'max_hours':None},
    'FACULTY_NO_CONSECUTIVE':     {'faculty_id':None,'max_consecutive':None},
    'FACULTY_FREE_DAY':           {'faculty_id':None,'preferred_day':None},
    'SUBJECT_PREFERRED_TIME':     {'subject_code':None,'period':None,'section_id':None,'priority':'SOFT'},
    'SUBJECT_SPACING':            {'subject_code':None,'section_id':None,'min_gap_days':None},
    'SUBJECT_AVOID_SLOT':         {'subject_code':None,'section_id':None,'avoid_first':False,'avoid_last':False},
    'LAB_MUST_CONSECUTIVE':       {'subject_code':None,'section_id':None,'num_slots':2},
    'HEAVY_SUBJECT_MORNING':      {'section_id':None},
    'NO_BACK_TO_BACK_SUBJECTS':   {'subject_codes':None,'section_id':None},
    'SECTION_PREFERRED_TIME':     {'section_id':None,'period':None,'priority':'SOFT'},
    'SECTION_FREE_SLOT':          {'section_id':None,'slot':None,'days':None},
    'SECTION_MAX_DAILY_SUBJECTS': {'section_id':None,'max_subjects':None},
    'LUNCH_BREAK':                {'slot':None,'sections':None},
    'ROOM_PREFERENCE':            {'subject_code':None,'faculty_id':None,'section_id':None,'room_id':None},
    'ELECTIVE_SAME_SLOT':         {'elective_group':None,'days':None},
    'DISTRIBUTE_SUBJECTS_EVENLY': {'subject_code':None,'section_id':None},
    'WORKING_DAYS':               {'days':None},
}
DAY_LOOKUP = {
    'monday':'MON','mon':'MON','tuesday':'TUE','tue':'TUE',
    'wednesday':'WED','wed':'WED','thursday':'THU','thu':'THU',
    'friday':'FRI','fri':'FRI','saturday':'SAT','sat':'SAT',
}
PERIOD_LOOKUP = {
    'morning':'MORNING','early':'MORNING','first half':'MORNING',
    'afternoon':'AFTERNOON','post-lunch':'AFTERNOON','second half':'AFTERNOON',
}

# ── Startup ───────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
    global MODEL, TOKENIZER, DEVICE
    try:
        import torch
        from transformers import T5ForConditionalGeneration, T5Tokenizer
        from huggingface_hub import snapshot_download

        print(f'HF_REPO   = {HF_REPO}')
        print(f'HF_TOKEN  = {"SET βœ…" if HF_TOKEN else "NOT SET ❌"}')
        print(f'MODEL_DIR = {MODEL_DIR}')

        if not os.path.exists(os.path.join(MODEL_DIR, 'config.json')):
            print('Downloading model from HuggingFace...')
            os.makedirs(MODEL_DIR, exist_ok=True)
            snapshot_download(
                repo_id=HF_REPO,
                local_dir=MODEL_DIR,
                token=HF_TOKEN if HF_TOKEN else None,
                ignore_patterns=['*.git*'],
            )
            print('βœ… Model downloaded')
        else:
            print('βœ… Model already cached')

        TOKENIZER = T5Tokenizer.from_pretrained(MODEL_DIR)
        MODEL     = T5ForConditionalGeneration.from_pretrained(MODEL_DIR)
        DEVICE    = 'cuda' if torch.cuda.is_available() else 'cpu'
        MODEL.to(DEVICE).eval()
        print(f'βœ… Model ready on {DEVICE}')

    except Exception as e:
        print(f'❌ STARTUP ERROR: {e}')
        import traceback
        traceback.print_exc()
        raise
    yield

# ── App ───────────────────────────────────────────────────────────────────
app = FastAPI(
    title='Timetable SLM API',
    description='Converts natural language timetable instructions into JSON constraints.',
    version='1.0.0',
    lifespan=lifespan,
)
app.add_middleware(CORSMiddleware, allow_origins=['*'],
                   allow_methods=['*'], allow_headers=['*'])

# ── Request models ────────────────────────────────────────────────────────
class SingleRequest(BaseModel):
    instruction: str

class BatchRequest(BaseModel):
    instructions: List[str]

# ── Helpers ───────────────────────────────────────────────────────────────
def _infer(prompt):
    import torch
    inp = TOKENIZER(PROMPT_PREFIX + prompt, return_tensors='pt',
                    max_length=MAX_INPUT, truncation=True).to(DEVICE)
    with torch.no_grad():
        out = MODEL.generate(
            **inp, max_new_tokens=MAX_OUTPUT, num_beams=4,
            no_repeat_ngram_size=4, repetition_penalty=1.5,
            early_stopping=True,
            decoder_start_token_id=TOKENIZER.pad_token_id,
            eos_token_id=TOKENIZER.eos_token_id,
            pad_token_id=TOKENIZER.pad_token_id,
        )
    return TOKENIZER.decode(out[0], skip_special_tokens=True)

def _parse(text):
    text = text.strip()
    text = re.sub(r'^```json\s*|^```\s*|```$', '', text, flags=re.MULTILINE).strip()
    try: return json.loads(text)
    except: pass
    t = text
    if not t.startswith('{'): t = '{' + t
    if not t.endswith('}'): t = t + '}'
    try: return json.loads(t)
    except: pass
    m = re.search(r'\{.*\}', text, re.DOTALL)
    if m:
        try: return json.loads(m.group())
        except: pass
    ctype = re.search(r'"type"\s*:\s*"([^"]+)"', text)
    if ctype:
        obj = {'type': ctype.group(1)}
        for f in ['faculty_id','subject_code','section_id','elective_group','room_id']:
            m2 = re.search(f'"{f}"\\s*:\\s*"([^"]+)"', text)
            if m2: obj[f] = m2.group(1)
        for f in ['max_hours','min_gap_days','num_slots','slot','max_subjects','max_consecutive']:
            m2 = re.search(f'"{f}"\\s*:\\s*(\\d+)', text)
            if m2: obj[f] = int(m2.group(1))
        return {'constraints': [obj]}
    return None

def _fix(c, prompt):
    c = dict(c)
    ctype = c.get('type','').upper()
    c['type'] = ctype
    if 'DAYS' in c and 'days' not in c: c['days'] = c.pop('DAYS')
    if ctype in ('FACULTY_PREFERRED_TIME','SUBJECT_PREFERRED_TIME','SECTION_PREFERRED_TIME'):
        if not c.get('priority'): c['priority'] = 'SOFT'
        if not c.get('period'):
            p = prompt.lower()
            for w, v in PERIOD_LOOKUP.items():
                if w in p: c['period'] = v; break
    if ctype in ('FACULTY_UNAVAILABLE','FACULTY_PREFERRED_TIME') and not c.get('days'):
        found = []
        for w, code in DAY_LOOKUP.items():
            if w in prompt.lower() and code not in found: found.append(code)
        if found: c['days'] = found
    if ctype == 'FACULTY_FREE_DAY' and not c.get('preferred_day'):
        for w, code in DAY_LOOKUP.items():
            if w in prompt.lower(): c['preferred_day'] = code; break
    if ctype == 'SUBJECT_AVOID_SLOT':
        if c.get('avoid_first') is None: c['avoid_first'] = False
        if c.get('avoid_last')  is None: c['avoid_last']  = False
    if ctype == 'LAB_MUST_CONSECUTIVE' and not c.get('num_slots'):
        c['num_slots'] = 2
    for field, default in CONSTRAINT_SCHEMA.get(ctype, {}).items():
        if field not in c: c[field] = default
    return c

def _process(instruction):
    t0  = time.time()
    raw = _infer(instruction)
    parsed = _parse(raw)
    ms = round((time.time() - t0) * 1000)
    if not parsed:
        return {'success':False,'instruction':instruction,
                'error':'parse_failed','raw':raw,'latency_ms':ms}
    fixed = [_fix(c, instruction) for c in parsed.get('constraints', [])]
    return {'success':True,'instruction':instruction,
            'constraints':fixed,'raw':raw,'latency_ms':ms}

# ── Endpoints ─────────────────────────────────────────────────────────────
@app.get('/')
def root():
    return {
        'service': 'Timetable SLM API',
        'status': 'running',
        'model': HF_REPO,
        'device': DEVICE,
        'docs': 'Visit /docs for interactive API documentation',
    }

@app.get('/health')
def health():
    return {'status':'ok','model_loaded':MODEL is not None,'device':DEVICE}

@app.post('/constraint')
def single(req: SingleRequest):
    if MODEL is None: raise HTTPException(503, 'Model not loaded')
    if not req.instruction.strip(): raise HTTPException(400, 'Empty instruction')
    return _process(req.instruction)

@app.post('/constraints/batch')
def batch(req: BatchRequest):
    if MODEL is None: raise HTTPException(503, 'Model not loaded')
    if not req.instructions: raise HTTPException(400, 'Empty list')
    results = [_process(i) for i in req.instructions]
    all_c   = [c for r in results if r['success'] for c in r['constraints']]
    failed  = [r['instruction'] for r in results if not r['success']]
    return {
        'success': len(failed) == 0,
        'total': len(results),
        'parsed': len(results) - len(failed),
        'failed': len(failed),
        'failed_list': failed,
        'all_constraints': all_c,
        'results': results,
    }