# app.py import csv import io import os import random from typing import List, Dict, Tuple import gradio as gr import numpy as np import pandas as pd from dotenv import load_dotenv load_dotenv() # ------------------------ # Genetic Algorithm Logic # ------------------------ class TimetableGA: def __init__( self, courses: List[str], teachers: List[str], rooms: List[str], days: List[str], slots: List[str], teacher_unavailable: Dict[str, List[Tuple[str, str]]], # teacher -> list of (day,slot) course_teacher_pref: Dict[str, List[str]], # course -> possible teachers room_constraints: Dict[str, List[str]], # course -> allowed rooms (optional) population_size: int = 100, generations: int = 200, mutation_rate: float = 0.05, ): self.courses = courses self.teachers = teachers self.rooms = rooms self.days = days self.slots = slots self.teacher_unavailable = teacher_unavailable self.course_teacher_pref = course_teacher_pref self.room_constraints = room_constraints or {} self.population_size = population_size self.generations = generations self.mutation_rate = mutation_rate self.times = [(d, s) for d in days for s in slots] self.num_periods = len(self.times) self.num_courses = len(courses) def _random_individual(self): """ Individual representation: - For each course, a tuple (period_index, room_index, teacher_index) - Represented as arrays: period_indices, room_indices, teacher_indices """ period_indices = np.random.randint(0, self.num_periods, size=self.num_courses) room_indices = np.random.randint(0, len(self.rooms), size=self.num_courses) teacher_indices = np.zeros(self.num_courses, dtype=int) for i, c in enumerate(self.courses): # choose teacher from preferences if provided, else random prefs = self.course_teacher_pref.get(c, None) if prefs: # pick one of allowed teachers teacher_indices[i] = self.teachers.index(random.choice(prefs)) else: teacher_indices[i] = np.random.randint(0, len(self.teachers)) return (period_indices, room_indices, teacher_indices) def _fitness(self, individual): period_indices, room_indices, teacher_indices = individual score = 0 penalties = 0 # 1) Teacher conflicts: a teacher cannot teach more than one course in same period teacher_period = {} for i, t_idx in enumerate(teacher_indices): key = (t_idx, int(period_indices[i])) teacher_period.setdefault(key, 0) teacher_period[key] += 1 teacher_conflicts = sum(max(0, cnt - 1) for cnt in teacher_period.values()) penalties += teacher_conflicts * 5 # 2) Room conflicts room_period = {} for i, r_idx in enumerate(room_indices): key = (r_idx, int(period_indices[i])) room_period.setdefault(key, 0) room_period[key] += 1 room_conflicts = sum(max(0, cnt - 1) for cnt in room_period.values()) penalties += room_conflicts * 5 # 3) Teacher availability violations avail_violations = 0 for i, t_idx in enumerate(teacher_indices): teacher = self.teachers[t_idx] period = self.times[int(period_indices[i])] if teacher in self.teacher_unavailable: if period in self.teacher_unavailable[teacher]: avail_violations += 1 penalties += avail_violations * 10 # 4) Course-teacher preference violations pref_violations = 0 for i, c in enumerate(self.courses): prefs = self.course_teacher_pref.get(c) if prefs: chosen_teacher = self.teachers[teacher_indices[i]] if chosen_teacher not in prefs: pref_violations += 1 penalties += pref_violations * 2 # 5) Room constraint violations room_violations = 0 for i, c in enumerate(self.courses): allowed = self.room_constraints.get(c) if allowed: chosen_room = self.rooms[room_indices[i]] if chosen_room not in allowed: room_violations += 1 penalties += room_violations * 4 # 6) Spread penalty (optional): same course multiple occurrences in same day slot collisions (if course repeated) # For simple use-case assume each course appears once - no extra penalty. # Fitness: higher is better. Start from base and subtract penalties. base = 1000 fitness = base - penalties # Provide components for debugging in return as well return fitness def _crossover(self, parent_a, parent_b): # single-point crossover on all arrays cut = np.random.randint(1, self.num_courses - 1) a_period, a_room, a_teacher = parent_a b_period, b_room, b_teacher = parent_b child1 = ( np.concatenate([a_period[:cut], b_period[cut:]]), np.concatenate([a_room[:cut], b_room[cut:]]), np.concatenate([a_teacher[:cut], b_teacher[cut:]]), ) child2 = ( np.concatenate([b_period[:cut], a_period[cut:]]), np.concatenate([b_room[:cut], a_room[cut:]]), np.concatenate([b_teacher[:cut], a_teacher[cut:]]), ) return child1, child2 def _mutate(self, individual): period_indices, room_indices, teacher_indices = individual for i in range(self.num_courses): if random.random() < self.mutation_rate: period_indices[i] = random.randint(0, self.num_periods - 1) if random.random() < self.mutation_rate: room_indices[i] = random.randint(0, len(self.rooms) - 1) if random.random() < self.mutation_rate: prefs = self.course_teacher_pref.get(self.courses[i], None) if prefs: teacher_indices[i] = self.teachers.index(random.choice(prefs)) else: teacher_indices[i] = random.randint(0, len(self.teachers) - 1) return (period_indices, room_indices, teacher_indices) def run(self, verbose=False): # Initialize population population = [self._random_individual() for _ in range(self.population_size)] fitnesses = [self._fitness(ind) for ind in population] best = population[np.argmax(fitnesses)] best_score = max(fitnesses) for gen in range(self.generations): # Selection (tournament) new_pop = [] while len(new_pop) < self.population_size: i1, i2 = random.sample(range(self.population_size), 2) p1 = population[i1] if fitnesses[i1] > fitnesses[i2] else population[i2] i3, i4 = random.sample(range(self.population_size), 2) p2 = population[i3] if fitnesses[i3] > fitnesses[i4] else population[i4] c1, c2 = self._crossover(p1, p2) c1 = self._mutate(c1) c2 = self._mutate(c2) new_pop.extend([c1, c2]) population = new_pop[: self.population_size] fitnesses = [self._fitness(ind) for ind in population] gen_best_idx = int(np.argmax(fitnesses)) gen_best_score = fitnesses[gen_best_idx] if gen_best_score > best_score: best_score = gen_best_score best = population[gen_best_idx] # early exit if perfect if best_score >= 1000: break if verbose and gen % max(1, self.generations // 10) == 0: print(f"Gen {gen} best {best_score}") return {"best": best, "score": best_score, "times": self.times} # ------------------------ # Helpers to convert individual -> dataframe # ------------------------ def individual_to_dataframe(individual, courses, teachers, rooms, times): period_indices, room_indices, teacher_indices = individual rows = [] for i, course in enumerate(courses): period_idx = int(period_indices[i]) day, slot = times[period_idx] rows.append( { "Course": course, "Teacher": teachers[int(teacher_indices[i])], "Room": rooms[int(room_indices[i])], "Day": day, "Slot": slot, } ) return pd.DataFrame(rows).sort_values(["Day", "Slot"]).reset_index(drop=True) def dataframe_to_csv_bytes(df: pd.DataFrame): buf = io.StringIO() df.to_csv(buf, index=False) return buf.getvalue().encode("utf-8") # ------------------------ # Gradio UI # ------------------------ def parse_multiline_list(text: str) -> List[str]: return [line.strip() for line in text.splitlines() if line.strip()] def parse_teacher_unavailability(text: str) -> Dict[str, List[Tuple[str,str]]]: # Format per line: Teacher,Day,Slot # Example: T1_Ali,Monday,Slot1 d = {} for ln in text.splitlines(): ln = ln.strip() if not ln: continue parts = [p.strip() for p in ln.split(",")] if len(parts) >= 3: teacher, day, slot = parts[0], parts[1], parts[2] d.setdefault(teacher, []).append((day, slot)) return d def parse_course_teacher_pref(text: str) -> Dict[str, List[str]]: # Format per line: Course: T1,T2 d = {} for ln in text.splitlines(): ln = ln.strip() if not ln: continue if ":" in ln: course, rest = ln.split(":", 1) teachers = [t.strip() for t in rest.split(",") if t.strip()] d[course.strip()] = teachers return d def parse_room_constraints(text: str) -> Dict[str, List[str]]: # Format per line: Course: R1,R2 d = {} for ln in text.splitlines(): ln = ln.strip() if not ln: continue if ":" in ln: course, rest = ln.split(":", 1) rooms = [r.strip() for r in rest.split(",") if r.strip()] d[course.strip()] = rooms return d def run_ga_and_return_csv( courses_text, teachers_text, rooms_text, days_text, slots_text, teacher_unavail_text, course_teacher_pref_text, room_constraints_text, pop_size, generations, mutation_rate, seed=42 ): random.seed(int(seed)) np.random.seed(int(seed)) courses = parse_multiline_list(courses_text) teachers = parse_multiline_list(teachers_text) rooms = parse_multiline_list(rooms_text) days = parse_multiline_list(days_text) slots = parse_multiline_list(slots_text) if not (courses and teachers and rooms and days and slots): return "Please provide at least one course, teacher, room, day, and slot.", None, None teacher_unavail = parse_teacher_unavailability(teacher_unavail_text) course_teacher_pref = parse_course_teacher_pref(course_teacher_pref_text) room_constraints = parse_room_constraints(room_constraints_text) ga = TimetableGA( courses=courses, teachers=teachers, rooms=rooms, days=days, slots=slots, teacher_unavailable=teacher_unavail, course_teacher_pref=course_teacher_pref, room_constraints=room_constraints, population_size=int(pop_size), generations=int(generations), mutation_rate=float(mutation_rate), ) result = ga.run(verbose=False) best = result["best"] score = result["score"] times = result["times"] df = individual_to_dataframe(best, courses, teachers, rooms, times) csv_bytes = dataframe_to_csv_bytes(df) summary = f"GA finished. Best fitness score: {score}. Generated timetable rows: {len(df)}" return summary, df, csv_bytes # ------------------------ # Build Gradio app # ------------------------ with gr.Blocks(title="Automatic Time Table Generation Agent (Genetic Algorithm)") as demo: gr.Markdown("# Automatic Time Table Generation Agent (Genetic Algorithm)") gr.Markdown( "Create an optimized timetable using a genetic algorithm. " "Enter courses, teachers, rooms, days and slots; optionally provide teacher unavailability, course-teacher preferences and room constraints." ) with gr.Row(): with gr.Column(scale=1): gr.Markdown("## Inputs") courses_in = gr.Textbox(label="Courses (one per line)", value="C1_Math\nC2_Physics\nC3_Chemistry\nC4_English\nC5_Biology\nC6_History", lines=6) teachers_in = gr.Textbox(label="Teachers (one per line)", value="T1_Ali\nT2_Sara\nT3_Omar\nT4_Fatima", lines=4) rooms_in = gr.Textbox(label="Rooms (one per line)", value="R1\nR2\nR3\nR4\nR5", lines=4) days_in = gr.Textbox(label="Days (one per line)", value="Monday\nTuesday\nWednesday\nThursday\nFriday", lines=5) slots_in = gr.Textbox(label="Slots (one per line)", value="Slot1\nSlot2\nSlot3\nSlot4\nSlot5\nSlot6", lines=6) gr.Markdown("### Optional constraints") teacher_unavail_in = gr.Textbox(label="Teacher unavailability (one per line: Teacher,Day,Slot)", value="", lines=4) course_teacher_pref_in = gr.Textbox(label="Course -> allowed teachers (one per line: Course: T1,T2)", value="", lines=4) room_constraints_in = gr.Textbox(label="Course -> allowed rooms (one per line: Course: R1,R2)", value="", lines=4) gr.Markdown("### GA parameters") pop_in = gr.Slider(label="Population size", minimum=10, maximum=500, value=120, step=10) gen_in = gr.Slider(label="Generations", minimum=10, maximum=2000, value=300, step=10) mut_in = gr.Slider(label="Mutation rate", minimum=0.0, maximum=0.5, value=0.05, step=0.01) seed_in = gr.Number(label="Random seed", value=42) run_btn = gr.Button("Run Genetic Algorithm") with gr.Column(scale=1): gr.Markdown("## Output") summary_out = gr.Textbox(label="Summary", lines=3) table_out = gr.Dataframe(headers=["Course","Teacher","Room","Day","Slot"], interactive=False) download_btn = gr.File(label="Download CSV") gr.Markdown("## Timetable Agent (Chat)") chat_in = gr.Textbox(label="Ask the Timetable Agent (explain conflicts, suggest fixes...)") chat_out = gr.Textbox(label="Agent response", lines=6) def run_and_prepare_download(*args): ( courses_text, teachers_text, rooms_text, days_text, slots_text, teacher_unavail_text, course_teacher_pref_text, room_constraints_text, pop_size, generations, mutation_rate, seed ) = args summary, df, csv_bytes = run_ga_and_return_csv( courses_text, teachers_text, rooms_text, days_text, slots_text, teacher_unavail_text, course_teacher_pref_text, room_constraints_text, pop_size, generations, mutation_rate, seed ) if df is None: return summary, None, None, None # prepare in-memory file for Gradio file_obj = io.BytesIO(csv_bytes) file_obj.name = "timetable.csv" return summary, df, file_obj, "Timetable generated. Ask the agent for an explanation." run_btn.click( run_and_prepare_download, inputs=[ courses_in, teachers_in, rooms_in, days_in, slots_in, teacher_unavail_in, course_teacher_pref_in, room_constraints_in, pop_in, gen_in, mut_in, seed_in ], outputs=[summary_out, table_out, download_btn, chat_out], show_progress=True, ) # Simple local "chat" behavior (not LLM) def simple_agent(query, summary_text, df): if not query: return "Type a question like: 'Which teachers have conflicts?' or 'Suggest 3 ways to reduce conflicts.'" if df is None or df.shape[0] == 0: return "No timetable available. Run the GA first." # Basic analysis text = query.lower() response_lines = [] if "conflict" in text or "conflicts" in text or "problem" in text: # detect teacher conflicts tconf = df.groupby(["Teacher","Day","Slot"]).size().reset_index(name="count") tconf = tconf[tconf["count"]>1] if not tconf.empty: response_lines.append("Teacher conflicts found:") for _, row in tconf.iterrows(): response_lines.append(f"- {row['Teacher']} has {row['count']} assignments on {row['Day']} {row['Slot']}") else: response_lines.append("No teacher conflicts detected.") # room conflicts rconf = df.groupby(["Room","Day","Slot"]).size().reset_index(name="count") rconf = rconf[rconf["count"]>1] if not rconf.empty: response_lines.append("Room conflicts found:") for _, row in rconf.iterrows(): response_lines.append(f"- {row['Room']} has {row['count']} assignments on {row['Day']} {row['Slot']}") else: response_lines.append("No room conflicts detected.") response_lines.append("Suggested fixes: 1) add rooms or change room constraints, 2) change teacher availability for offending periods, 3) allow alternate teachers for affected courses.") return "\n".join(response_lines) if "suggest" in text or "improve" in text or "reduce" in text: return "Three quick suggestions:\n1) Increase number of rooms or relax room constraints.\n2) Allow more teachers per course (course-teacher preferences).\n3) Move a class to a different slot/day for teachers with conflicts." # default explanation: show top 5 assignments sample = df.head(8).to_string(index=False) return f"Timetable summary (first rows):\n{sample}\n\nAsk for 'conflicts' or 'suggestions' for improvement." chat_btn = gr.Button("Ask Agent") chat_btn.click(simple_agent, inputs=[chat_in, summary_out, table_out], outputs=[chat_out]) gr.Markdown("### Notes\n- This genetic algorithm is a demonstrative solver. For production use you should add stronger constraints (room capacities, repeating lessons, student group clashes) and tune GA parameters.") if __name__ == "__main__": demo.launch(server_name="0.0.0.0", share=False)