Spaces:
Sleeping
Sleeping
| # 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def root(): | |
| return { | |
| 'service': 'Timetable SLM API', | |
| 'status': 'running', | |
| 'model': HF_REPO, | |
| 'device': DEVICE, | |
| 'docs': 'Visit /docs for interactive API documentation', | |
| } | |
| def health(): | |
| return {'status':'ok','model_loaded':MODEL is not None,'device':DEVICE} | |
| 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) | |
| 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, | |
| } |