File size: 17,807 Bytes
210d88d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# 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 {}