timetable_gen / slm_inference.py
KindAlien's picture
Super-squash branch 'main' using huggingface_hub
210d88d
Raw
History Blame Contribute Delete
17.8 kB
# slm_inference.py
import re, requests
from typing import List, Dict, Any, Optional
API_URL = 'https://vishwasmsme-timetable-slm-api.hf.space'
DAY_WORDS = {
'monday':'MON','tuesday':'TUE','wednesday':'WED',
'thursday':'THU','friday':'FRI','saturday':'SAT',
'mon':'MON','tue':'TUE','wed':'WED',
'thu':'THU','fri':'FRI','sat':'SAT',
}
def get_constraint(instruction):
try:
r = requests.post(f'{API_URL}/constraint',json={'instruction':instruction},timeout=60)
r.raise_for_status(); return r.json()
except Exception as e:
return {'success':False,'error':str(e),'constraints':[]}
def get_constraints_batch(instructions):
try:
r = requests.post(f'{API_URL}/constraints/batch',json={'instructions':instructions},timeout=120)
r.raise_for_status(); return r.json().get('all_constraints',[])
except Exception as e:
print(f'API Error: {e}'); return []
def check_api_health():
try:
return requests.get(f'{API_URL}/health',timeout=10).json().get('model_loaded',False)
except: return False
def smart_parse(prompt, faculties_data, subjects_data=None, sections_data=None):
p = prompt.lower().strip()
fac_map = _build_fac_map(faculties_data)
sub_map = _build_sub_map(subjects_data or [])
days = _extract_all_days(p)
period = _extract_period(p)
# NO FREE PERIOD — "first hour should not be free", "period 1 must have a class"
no_free_kw = ['should not be free', 'must not be free', 'cannot be free',
'should always be occupied', 'always be occupied', 'always occupied',
'no free slot in', 'free slot in period', 'no free in',
'must have a class', 'should have a class', 'always occupied',
'no free period', 'no free slot', 'must be filled',
'first hour should not', 'last period should not']
if any(kw in p for kw in no_free_kw):
# Detect which period(s) are required
periods = []
if any(w in p for w in ['first hour', 'period 1', 'p1', 'first period', 'slot 1']):
periods = [0] # 0-indexed: P1
elif any(w in p for w in ['last hour', 'last period', 'last slot']):
periods = [-1] # will resolve to last period dynamically
elif any(w in p for w in ['second period', 'period 2', 'p2']):
periods = [1]
elif any(w in p for w in ['third period', 'period 3', 'p3']):
periods = [2]
else:
# Try to extract any period number mentioned
nums = re.findall(r'period\s*(\d+)|p\s*(\d+)|slot\s*(\d+)', p)
for g in nums:
n = next((int(x) for x in g if x), None)
if n: periods.append(n - 1)
if not periods:
periods = [0] # default: first period
return {
'type': 'NO_FREE_PERIOD',
'periods': periods,
'section_id': _extract_section(p, sections_data),
}
# FACULTY SUBSTITUTION
if any(kw in p for kw in ['replace','substitute','take over','will take','on leave',
'covering','cover for','instead of','will cover','deputed']):
facs = _find_two_faculties(p, fac_map)
if facs:
return {'type':'FACULTY_SUBSTITUTION','from_faculty_id':facs[0],
'to_faculty_id':facs[1],'days':days,
'subject_codes':_extract_subjects(p,sub_map)}
# NO SUBJECT ON DAY — "No TOC on Thursday", "No NLP class on Friday for 5A"
no_subj_kw = ['no toc', 'no nlp', 'no ml', 'no cn', 'no sepm', 'no nosql',
'no rmipr', 'no iks', 'no evs', 'no dvlab', 'no cnlab',
'no nlplab', 'no mllab', 'no miniproj', 'no major', 'no genai',
'no devops', 'no hcai', 'no cc ', 'no forum', 'no softskill']
has_no_subj = any(kw in p for kw in no_subj_kw)
# Also catch "no [subject_name] class/on/for"
has_no_class = bool(re.search(r'no\s+\w+\s+(class|on|for)', p))
if has_no_subj or has_no_class:
subs = _extract_subjects(p, sub_map)
if subs and days:
return {
'type': 'CANCEL_CLASS',
'subject_code': subs[0],
'day': days[0],
'section_id': _extract_section(p, sections_data),
'faculty_id': None,
'period': None,
}
# CANCEL / HOLIDAY
holiday_kws = ['holiday','off day','public holiday','is a holiday','college is closed',
'closed on','college closed','no classes on','no classes for all',
'is holiday']
cancel_kws2 = ['cancel','no class','no lecture','cancelled','remove','delete','drop']
is_holiday = any(kw in p for kw in holiday_kws)
is_cancel = any(kw in p for kw in cancel_kws2)
# Whole-day clear: holiday OR (cancel/no classes with NO subject mentioned)
subs_found = _extract_subjects(p, sub_map)
fid_found = _extract_one_faculty(p, fac_map)
whole_day = is_holiday or (is_cancel and not subs_found and not fid_found and not period)
if (is_holiday or is_cancel) and days:
if whole_day:
return {'type':'MARK_HOLIDAY', 'days':days,
'section_id':_extract_section(p, sections_data)}
return {'type':'CANCEL_CLASS', 'day':days[0], 'period':period,
'subject_code': subs_found[0] if subs_found else None,
'section_id': _extract_section(p, sections_data),
'faculty_id': fid_found}
# CHANGE ROOM — detect "to Lab 2", "to R3", "shift all labs to L3"
# Must run BEFORE move/reschedule to prevent "Move NLPLAB to Lab 2" → RESCHEDULE_LAB
room_match = re.search(r'\b(lab\s*\d+|l\d+|r\d+|room\s*\d+)\b', p)
room_val = room_match.group(1).replace(' ','').upper() if room_match else None
room_dest_kws = ['to lab ', 'to l', 'to r', 'to room', 'change room',
'assign room', 'move to lab', 'move to room', 'labs to']
has_room_dest = any(kw in p for kw in room_dest_kws) and room_val
if has_room_dest:
subs_r = _extract_subjects(p, sub_map)
return {'type': 'CHANGE_ROOM',
'new_room_id': room_val,
'room_id': room_val,
'subject_code': subs_r[0] if subs_r else None,
'section_id': _extract_section(p, sections_data),
'day': days[0] if days else None,
'all_labs': 'all' in p and 'lab' in p}
# MOVE / RESCHEDULE — only if NOT a cancel/remove keyword
move_kws = ['move','shift','reschedule','transfer','relocate']
cancel_kws = ['cancel','remove','delete','drop','no class','holiday']
is_move = any(kw in p for kw in move_kws)
is_cancel = any(kw in p for kw in cancel_kws)
if is_move and not is_cancel:
subs = _extract_subjects(p, sub_map)
is_lab = ('lab' in p or (subs and any(s.lower().endswith('lab') for s in subs)))
if is_lab and subs:
# "from X to Y" → days[0]=from, days[1]=to
# "to Y" only → days[0]=to, from_day=None
from_day = days[0] if len(days) >= 2 else None
to_day = days[1] if len(days) >= 2 else (days[0] if days else None)
return {'type':'RESCHEDULE_LAB',
'subject_code': subs[0],
'section_id': _extract_section(p, sections_data),
'from_day': from_day,
'to_day': to_day,
'to_period': period or 1}
if len(days) >= 2:
subs = _extract_subjects(p, sub_map)
return {'type':'MOVE_CLASS',
'from_day': days[0],
'to_day': days[1],
'subject_code': subs[0] if subs else None,
'section_id': _extract_section(p, sections_data),
'faculty_id': _extract_one_faculty(p, fac_map)}
# CHANGE ROOM
if any(kw in p for kw in ['change room','move to room','to lab','to room','assign room']):
rm = re.search(r'\b([rl]\d+|lab\s*\d*|room\s*\d+)\b', p)
return {'type':'CHANGE_ROOM',
'room_id':rm.group(1).replace(' ','') if rm else None,
'subject_code':(_extract_subjects(p,sub_map) or [None])[0],
'section_id':_extract_section(p,sections_data),
'day':days[0] if days else None}
# ADD EXTRA / MAKEUP
# Check for "schedule [subject] session/class" pattern like "Schedule extra ML session"
import re as _re
has_schedule_class = bool(_re.search(
r'(schedule|add)\s+(extra|additional|makeup|compensatory)?\s*\w+\s*(class|session)', p))
extra_keywords = ['extra class','makeup','additional class','make up',
'compensatory','add class','schedule extra','extra session',
'additional session','schedule.*class','add.*session']
if has_schedule_class or any(kw in p for kw in extra_keywords):
subs = _extract_subjects(p, sub_map)
return {'type':'ADD_EXTRA_CLASS',
'subject_code':subs[0] if subs else None,
'section_id':_extract_section(p,sections_data),
'faculty_id':_extract_one_faculty(p,fac_map),
'day':days[0] if days else None,
'period': period or (5 if 'afternoon' in p else
1 if 'morning' in p else None)}
# CHANGE FACULTY (permanent)
perm_kws = ['permanently','all classes','change faculty','hand over',
'assign all','transfer all']
if any(kw in p for kw in perm_kws):
facs = _find_two_faculties(p, fac_map) or []
scodes = _extract_subjects(p, sub_map)
# "Assign all X to Y" / "Transfer A's classes to B" → to_fac = LAST mentioned
# "Change faculty from A to B" → from=A (first), to=B (second)
if len(facs) >= 2:
# "from A to B" order → from=facs[0], to=facs[1]
if 'from' in p and 'to' in p:
from_fac, to_fac = facs[0], facs[1]
else:
# "assign all ... to Syed" → to_fac is the one after "to"
to_idx = p.rfind(' to ')
if to_idx > 0:
after_to = p[to_idx+4:]
to_fac = next((fi for n,fi in fac_map.items() if n and n in after_to), facs[-1])
from_fac = next((fi for fi in facs if fi != to_fac), facs[0])
else:
from_fac, to_fac = facs[0], facs[1]
elif len(facs) == 0:
# Try single faculty extraction
single = _extract_one_faculty(p, fac_map)
from_fac, to_fac = None, single
elif len(facs) == 1:
# Only one faculty mentioned — they're the target (to_fac)
from_fac, to_fac = None, facs[0]
else:
from_fac, to_fac = None, None
if to_fac or (scodes and not facs):
# If no faculty found but subject found, still create constraint
# (partial — will match by subject code only)
return {'type':'CHANGE_FACULTY',
'from_faculty_id': from_fac,
'to_faculty_id': to_fac,
'subject_codes': scodes,
'subject_code': scodes[0] if scodes else None,
'section_id':_extract_section(p,sections_data)}
# SWAP
if any(kw in p for kw in ['swap','exchange']):
import re as _re
periods = _re.findall(r'p(?:eriod)?\s*(\d+)', p)
subs = _extract_subjects(p, sub_map)
# Extract faculty ids in order of appearance in prompt
fac_found = sorted(
[(name, fid, p.index(name)) for name, fid in fac_map.items()
if name and name in p],
key=lambda x: x[2]
)
# Deduplicate by fid keeping first occurrence
seen, fac_ids = set(), []
for _, fid, _ in fac_found:
if fid not in seen:
fac_ids.append(fid); seen.add(fid)
return {'type': 'SWAP_CLASSES',
'day1': days[0] if len(days) > 0 else None,
'day2': days[1] if len(days) > 1 else None,
'period1': int(periods[0]) if len(periods) > 0 else None,
'period2': int(periods[1]) if len(periods) > 1 else None,
'subject_code1': subs[0] if len(subs) > 0 else None,
'subject_code2': subs[1] if len(subs) > 1 else None,
'subject_codes': subs,
'faculty_id1': fac_ids[0] if len(fac_ids) > 0 else None,
'faculty_id2': fac_ids[1] if len(fac_ids) > 1 else None,
'section_id': _extract_section(p, sections_data)}
# FREEZE
if any(kw in p for kw in ['freeze','lock slot','fix this slot','keep this',
'lock ','don\'t move','do not move','should not change',
'it should not change','fix mllab','fix nlplab',
'fix cnlab','fix dvlab']):
subs = _extract_subjects(p, sub_map)
return {'type':'FREEZE_SLOT','day':days[0] if days else None,'period':period,
'subject_code':subs[0] if subs else None,
'section_id':_extract_section(p,sections_data)}
return None
def _build_fac_map(fds):
m = {}
for f in fds:
m[f['name'].lower()] = f['id']
short = re.sub(r'(prof\.|dr\.|mr\.|ms\.)\s*','',f['name'],flags=re.I).strip()
m[short.lower()] = f['id']
m[f['id'].lower()] = f['id']
return m
def _build_sub_map(sds):
m = {}
for s in sds:
m[s['name'].lower()] = s['code']
m[s['code'].lower()] = s['code']
return m
def _extract_all_days(text):
found, seen = [], set()
for w, d in DAY_WORDS.items():
idx = text.find(w)
if idx >= 0 and d not in seen:
found.append((idx,d)); seen.add(d)
found.sort(); return [d for _,d in found]
def _extract_period(text):
m = re.search(r'p(?:eriod)?\s*(\d+)', text)
return int(m.group(1)) if m else None
def _extract_subjects(text, sub_map):
# Sort by name length descending so "nlplab" matches before "nlp"
sorted_items = sorted(sub_map.items(), key=lambda x: -len(x[0]))
seen_codes, seen_names, results = set(), set(), []
for name, code in sorted_items:
if not name or name not in text: continue
if code in seen_codes: continue
# Skip if a longer name already matched that contains this name
# e.g. skip "nlp" if "nlplab" already matched
already_covered = any(matched for matched in seen_names if name in matched)
if already_covered: continue
results.append(code)
seen_codes.add(code)
seen_names.add(name)
return results
def _extract_one_faculty(text, fac_map):
for name,fid in fac_map.items():
if name and name in text: return fid
return None
def _find_two_faculties(text, fac_map):
found = []
for name,fid in sorted(fac_map.items(), key=lambda x:-len(x[0])):
if name and name in text and fid not in found:
found.append(fid)
if len(found)==2: break
return found if len(found)==2 else None
def _extract_section(text, sections_data):
if not sections_data: return None
# Sort by length descending so "6a-e1" matches before "6a"
for s in sorted(sections_data, key=lambda x: -len(x['id'])):
if s['id'].lower() in text:
return s['id']
# Match shorthand like "6a" -> return prefix as parent filter
# e.g. "6a" matches 6a-E1 AND 6a-E2 -> return "6A" (prefix)
# caller uses this to filter all sub-sections of 6A
prefixes = {}
for s in sections_data:
prefix = s['id'].split('-')[0].lower()
prefixes.setdefault(prefix, []).append(s['id'])
for prefix, ids in sorted(prefixes.items(), key=lambda x: -len(x[0])):
pattern = r'(?<![a-z0-9])' + re.escape(prefix) + r'(?![a-z0-9])'
if re.search(pattern, text):
if len(ids) == 1:
return ids[0] # unique — return full id
else:
return prefix.upper() # ambiguous — return parent "6A", "5A" etc.
return None
# Sort by length descending so "6a-e1" matches before "6a"
for s in sorted(sections_data, key=lambda x: -len(x['id'])):
if s['id'].lower() in text:
return s['id']
# Match shorthand like "6a" -> returns None if multiple sections share prefix
# e.g. "6a" matches 6a-E1 AND 6a-E2, so return None (move all of them)
prefixes = {}
for s in sections_data:
prefix = s['id'].split('-')[0].lower()
prefixes.setdefault(prefix, []).append(s['id'])
for prefix, ids in prefixes.items():
if re.search(r'' + prefix + r'', text):
if len(ids) == 1:
return ids[0] # unique match
# multiple sections with same prefix — return None (affects all)
return None
def parse_substitution_prompt(prompt, faculties_data):
result = smart_parse(prompt, faculties_data)
return result if result and result.get('type')=='FACULTY_SUBSTITUTION' else {}