blackopsrepl commited on
Commit
e3a1efe
Β·
1 Parent(s): 50e3252

feat!: add task pinning system and refactor existing systems

Browse files
README.md CHANGED
@@ -82,8 +82,10 @@ Yuga Planner follows a **service-oriented architecture** with clear separation o
82
  | **Live Log Streaming** | Real-time solver progress and status updates in UI | βœ… |
83
  | **Configurable Parameters** | Adjustable employee count and schedule duration | βœ… |
84
  | **Mock Project Loading** | Pre-configured sample projects for quick testing | βœ… |
85
- | **Calendar Parsing** | Extracts tasks from uploaded calendar files (.ics) | βœ… |
86
- | **MCP Endpoint** | API endpoint for MCP tool integration | βœ… |
 
 
87
 
88
  ## 🎯 Two Usage Modes
89
 
@@ -123,9 +125,8 @@ available time slots around your existing meetings
123
  - Designed for seamless chatbot and agent workflow integration
124
 
125
  **Current Limitations:**
126
- - **Weekend constraints:** Tasks can be scheduled on weekends (should respect work-week boundaries)
127
- - **Working hours:** No enforcement of standard business hours (8 AM - 6 PM)
128
- - **Calendar pinning:** Tasks from uploaded calendars are solved alongside other tasks but should remain pinned to their original time slots
129
 
130
  See the [CHANGELOG.md](CHANGELOG.md) for details on recent MCP-related changes.
131
 
@@ -138,14 +139,10 @@ See the [CHANGELOG.md](CHANGELOG.md) for details on recent MCP-related changes.
138
 
139
  ### Work in Progress
140
 
141
- - **Constraint Enhancements:**
142
- - Weekend respect (prevent scheduling on weekends)
143
- - Working hours enforcement (8 AM - 6 PM business hours)
144
- - Calendar task pinning (preserve original time slots for imported calendar events)
145
- - **Gradio UI overhaul:** Enhanced user experience and visual improvements
146
- - **Migration to Pydantic models:** Type-safe data validation and serialization
147
- - **Migrate from violation_analyzer to Timefold dedicated libraries**
148
- - **Include tests for all constraints using ConstraintVerifier**
149
 
150
  ### Future Work
151
 
 
82
  | **Live Log Streaming** | Real-time solver progress and status updates in UI | βœ… |
83
  | **Configurable Parameters** | Adjustable employee count and schedule duration | βœ… |
84
  | **Mock Project Loading** | Pre-configured sample projects for quick testing | βœ… |
85
+ | **Calendar Parsing & Pinning** | Extracts and preserves calendar events from .ics files at original times | βœ… |
86
+ | **Business Hours Enforcement** | Respects 9:00-18:00 working hours with lunch break exclusion | βœ… |
87
+ | **Weekend Scheduling Prevention** | Hard constraint preventing weekend task assignments | βœ… |
88
+ | **MCP Endpoint** | API endpoint for MCP tool integration with calendar support | βœ… |
89
 
90
  ## 🎯 Two Usage Modes
91
 
 
125
  - Designed for seamless chatbot and agent workflow integration
126
 
127
  **Current Limitations:**
128
+ - **Cross-system integration:** Gradio web demo and MCP personal tool operate as separate systems
129
+ - **Multi-timezone support:** Currently operates in a single timezone context with UTC conversion for consistency. Calendar events from different timezones are normalized to the same scheduling context.
 
130
 
131
  See the [CHANGELOG.md](CHANGELOG.md) for details on recent MCP-related changes.
132
 
 
139
 
140
  ### Work in Progress
141
 
142
+ - **πŸ”§ Gradio UI overhaul:** Enhanced user experience and visual improvements
143
+ - **πŸ” Migration to Pydantic models:** Type-safe data validation and serialization
144
+ - **πŸ“š Migrate from violation_analyzer to Timefold dedicated libraries**
145
+ - **⚑ Enhanced timezone support:** Multi-timezone calendar integration for international scheduling
 
 
 
 
146
 
147
  ### Future Work
148
 
src/constraint_solvers/timetable/constraints.py CHANGED
@@ -6,6 +6,8 @@ from .working_hours import (
6
  get_working_day_from_slot,
7
  get_slot_within_day,
8
  task_spans_lunch_break,
 
 
9
  )
10
 
11
  from timefold.solver.score import HardSoftDecimalScore
@@ -32,37 +34,7 @@ def get_slot_overlap(task1: Task, task2: Task) -> int:
32
  return max(0, overlap_end - overlap_start)
33
 
34
 
35
- def get_slot_date(slot: int) -> date:
36
- """Convert a slot index to a date.
37
-
38
- For compatibility with tests, slot 0 = today, slot 16 = tomorrow, etc.
39
- In production, weekends would be filtered out, but for tests we keep simple mapping.
40
-
41
- Args:
42
- slot (int): The slot index.
43
-
44
- Returns:
45
- date: The date corresponding to the slot.
46
- """
47
- working_day = get_working_day_from_slot(slot)
48
- today = date.today()
49
- return today + timedelta(days=working_day)
50
-
51
-
52
- def is_weekend_slot(slot: int) -> bool:
53
- """Check if a slot falls on a weekend.
54
-
55
- Since our slot system only includes working days, this should always return False
56
- for valid slots, but we keep it for validation purposes.
57
-
58
- Args:
59
- slot (int): The slot index.
60
-
61
- Returns:
62
- bool: True if the slot would fall on a weekend.
63
- """
64
- slot_date = get_slot_date(slot)
65
- return slot_date.weekday() >= 5 # Saturday=5, Sunday=6
66
 
67
 
68
  def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
@@ -183,9 +155,11 @@ def task_fits_in_schedule(constraint_factory: ConstraintFactory):
183
  def unavailable_employee(constraint_factory: ConstraintFactory):
184
  return (
185
  constraint_factory.for_each(Task)
 
186
  .filter(
187
- lambda task: task.employee is not None
188
- and get_slot_date(task.start_slot) in task.employee.unavailable_dates
 
189
  )
190
  .penalize(HardSoftDecimalScore.ONE_HARD)
191
  .as_constraint("Unavailable employee")
@@ -215,9 +189,11 @@ def no_weekend_scheduling(constraint_factory: ConstraintFactory):
215
  def undesired_day_for_employee(constraint_factory: ConstraintFactory):
216
  return (
217
  constraint_factory.for_each(Task)
 
218
  .filter(
219
- lambda task: task.employee is not None
220
- and get_slot_date(task.start_slot) in task.employee.undesired_dates
 
221
  )
222
  .penalize(HardSoftDecimalScore.ONE_SOFT)
223
  .as_constraint("Undesired day for employee")
@@ -227,9 +203,11 @@ def undesired_day_for_employee(constraint_factory: ConstraintFactory):
227
  def desired_day_for_employee(constraint_factory: ConstraintFactory):
228
  return (
229
  constraint_factory.for_each(Task)
 
230
  .filter(
231
- lambda task: task.employee is not None
232
- and get_slot_date(task.start_slot) in task.employee.desired_dates
 
233
  )
234
  .reward(HardSoftDecimalScore.ONE_SOFT)
235
  .as_constraint("Desired day for employee")
 
6
  get_working_day_from_slot,
7
  get_slot_within_day,
8
  task_spans_lunch_break,
9
+ is_weekend_slot,
10
+ get_slot_date,
11
  )
12
 
13
  from timefold.solver.score import HardSoftDecimalScore
 
34
  return max(0, overlap_end - overlap_start)
35
 
36
 
37
+ # Note: get_slot_date and is_weekend_slot are now imported from working_hours
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  def tasks_violate_sequence_order(task1: Task, task2: Task) -> bool:
 
155
  def unavailable_employee(constraint_factory: ConstraintFactory):
156
  return (
157
  constraint_factory.for_each(Task)
158
+ .join(ScheduleInfo)
159
  .filter(
160
+ lambda task, schedule_info: task.employee is not None
161
+ and get_slot_date(task.start_slot, schedule_info.base_date)
162
+ in task.employee.unavailable_dates
163
  )
164
  .penalize(HardSoftDecimalScore.ONE_HARD)
165
  .as_constraint("Unavailable employee")
 
189
  def undesired_day_for_employee(constraint_factory: ConstraintFactory):
190
  return (
191
  constraint_factory.for_each(Task)
192
+ .join(ScheduleInfo)
193
  .filter(
194
+ lambda task, schedule_info: task.employee is not None
195
+ and get_slot_date(task.start_slot, schedule_info.base_date)
196
+ in task.employee.undesired_dates
197
  )
198
  .penalize(HardSoftDecimalScore.ONE_SOFT)
199
  .as_constraint("Undesired day for employee")
 
203
  def desired_day_for_employee(constraint_factory: ConstraintFactory):
204
  return (
205
  constraint_factory.for_each(Task)
206
+ .join(ScheduleInfo)
207
  .filter(
208
+ lambda task, schedule_info: task.employee is not None
209
+ and get_slot_date(task.start_slot, schedule_info.base_date)
210
+ in task.employee.desired_dates
211
  )
212
  .reward(HardSoftDecimalScore.ONE_SOFT)
213
  .as_constraint("Desired day for employee")
src/constraint_solvers/timetable/domain.py CHANGED
@@ -2,7 +2,7 @@ from timefold.solver import SolverStatus
2
  from timefold.solver.domain import *
3
  from timefold.solver.score import HardSoftDecimalScore
4
 
5
- from datetime import date
6
  from typing import Annotated
7
  from dataclasses import dataclass, field
8
 
@@ -99,13 +99,35 @@ class Task:
99
  @dataclass
100
  class ScheduleInfo:
101
  total_slots: int # Total number of 30-minute slots in the schedule
 
 
102
 
103
  def to_dict(self):
104
- return {"total_slots": self.total_slots}
 
 
 
 
105
 
106
  @staticmethod
107
  def from_dict(d):
108
- return ScheduleInfo(total_slots=d["total_slots"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
 
110
 
111
  @planning_solution
@@ -124,8 +146,17 @@ class EmployeeSchedule:
124
  def get_start_slot_range(
125
  self,
126
  ) -> Annotated[list[int], ValueRangeProvider(id="startSlotRange")]:
127
- """Returns all possible start slots."""
128
- return list(range(self.schedule_info.total_slots))
 
 
 
 
 
 
 
 
 
129
 
130
  def to_dict(self):
131
  return {
 
2
  from timefold.solver.domain import *
3
  from timefold.solver.score import HardSoftDecimalScore
4
 
5
+ from datetime import date, timezone
6
  from typing import Annotated
7
  from dataclasses import dataclass, field
8
 
 
99
  @dataclass
100
  class ScheduleInfo:
101
  total_slots: int # Total number of 30-minute slots in the schedule
102
+ base_date: date = None # Base date for slot 0 (optional, defaults to today)
103
+ base_timezone: timezone = None # Timezone for datetime conversions (optional)
104
 
105
  def to_dict(self):
106
+ return {
107
+ "total_slots": self.total_slots,
108
+ "base_date": self.base_date.isoformat() if self.base_date else None,
109
+ "base_timezone": str(self.base_timezone) if self.base_timezone else None,
110
+ }
111
 
112
  @staticmethod
113
  def from_dict(d):
114
+ base_date = None
115
+ if d.get("base_date"):
116
+ base_date = date.fromisoformat(d["base_date"])
117
+
118
+ base_timezone = None
119
+ if d.get("base_timezone"):
120
+ # Simple timezone parsing - extend as needed
121
+ tz_str = d["base_timezone"]
122
+ if tz_str == "UTC" or "+00:00" in tz_str:
123
+ base_timezone = timezone.utc
124
+ # Add more timezone parsing as needed
125
+
126
+ return ScheduleInfo(
127
+ total_slots=d["total_slots"],
128
+ base_date=base_date,
129
+ base_timezone=base_timezone,
130
+ )
131
 
132
 
133
  @planning_solution
 
146
  def get_start_slot_range(
147
  self,
148
  ) -> Annotated[list[int], ValueRangeProvider(id="startSlotRange")]:
149
+ """Returns all possible start slots, including slots used by pinned tasks."""
150
+ max_slot = self.schedule_info.total_slots
151
+
152
+ # Ensure all pinned task slots are included in the range
153
+ for task in self.tasks:
154
+ if getattr(task, "pinned", False):
155
+ task_end_slot = task.start_slot + task.duration_slots
156
+ if task_end_slot > max_slot:
157
+ max_slot = task_end_slot
158
+
159
+ return list(range(max_slot))
160
 
161
  def to_dict(self):
162
  return {
src/constraint_solvers/timetable/working_hours.py CHANGED
@@ -2,10 +2,46 @@
2
  # WORKING HOURS CONFIG
3
  # =========================
4
 
5
- # Working hours: 9:00-13:00 (8 slots) + 14:00-18:00 (8 slots) = 16 slots per working day
6
- SLOTS_PER_WORKING_DAY = 16
 
7
  MORNING_SLOTS = 8 # 9:00-13:00 (4 hours * 2 slots/hour)
8
- AFTERNOON_SLOTS = 8 # 14:00-18:00 (4 hours * 2 slots/hour)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
 
11
  def get_working_day_from_slot(slot: int) -> int:
@@ -21,19 +57,19 @@ def get_working_day_from_slot(slot: int) -> int:
21
 
22
 
23
  def get_slot_within_day(slot: int) -> int:
24
- """Get the slot position within a working day (0-15).
25
 
26
  Args:
27
  slot (int): The slot index.
28
 
29
  Returns:
30
- int: The slot position within the day (0-15).
31
  """
32
  return slot % SLOTS_PER_WORKING_DAY
33
 
34
 
35
  def task_spans_lunch_break(task) -> bool:
36
- """Check if a task spans across the lunch break period.
37
 
38
  Args:
39
  task: The task to check.
@@ -44,5 +80,41 @@ def task_spans_lunch_break(task) -> bool:
44
  start_slot_in_day = get_slot_within_day(task.start_slot)
45
  end_slot_in_day = start_slot_in_day + task.duration_slots - 1
46
 
47
- # If task starts in morning (0-7) and ends in afternoon (8-15), it spans lunch
48
- return start_slot_in_day < MORNING_SLOTS and end_slot_in_day >= MORNING_SLOTS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  # WORKING HOURS CONFIG
3
  # =========================
4
 
5
+ # Working hours: 9:00-18:00 (20 slots) = 20 slots per working day
6
+ # Each slot is 30 minutes, starting at 9:00 AM
7
+ SLOTS_PER_WORKING_DAY = 20 # 9:00-18:00 (9 hours * 2 slots/hour)
8
  MORNING_SLOTS = 8 # 9:00-13:00 (4 hours * 2 slots/hour)
9
+ AFTERNOON_SLOTS = 10 # 14:00-18:00 (4 hours * 2 slots/hour)
10
+ LUNCH_BREAK_START_SLOT = 8 # 13:00-14:00
11
+ LUNCH_BREAK_END_SLOT = 10 # 14:00
12
+
13
+ from datetime import datetime, date, time, timezone, timedelta
14
+
15
+
16
+ def slot_to_datetime(slot: int, base_date: date = None, base_timezone=None) -> datetime:
17
+ """
18
+ Convert a slot index to a naive datetime in local time, accounting for working days.
19
+
20
+ Args:
21
+ slot: The slot index (each slot = 30 minutes within working hours)
22
+ base_date: Base date for slot 0 (defaults to today)
23
+ base_timezone: Ignored (kept for API compatibility)
24
+
25
+ Returns:
26
+ datetime: The corresponding naive datetime in local time
27
+ """
28
+ if base_date is None:
29
+ base_date = date.today()
30
+
31
+ # Calculate which working day and slot within that day
32
+ working_day = get_working_day_from_slot(slot)
33
+ slot_within_day = get_slot_within_day(slot)
34
+
35
+ # Get the actual calendar date for this working day
36
+ target_date = base_date + timedelta(days=working_day)
37
+
38
+ # Calculate time within the working day (9:00 AM + slot_within_day * 30 minutes)
39
+ minutes_from_9am = slot_within_day * 30
40
+ target_time = datetime.combine(
41
+ target_date, datetime.min.time().replace(hour=9)
42
+ ) + timedelta(minutes=minutes_from_9am)
43
+
44
+ return target_time
45
 
46
 
47
  def get_working_day_from_slot(slot: int) -> int:
 
57
 
58
 
59
  def get_slot_within_day(slot: int) -> int:
60
+ """Get the slot position within a working day (0-19).
61
 
62
  Args:
63
  slot (int): The slot index.
64
 
65
  Returns:
66
+ int: The slot position within the day (0-19).
67
  """
68
  return slot % SLOTS_PER_WORKING_DAY
69
 
70
 
71
  def task_spans_lunch_break(task) -> bool:
72
+ """Check if a task spans across the lunch break period (13:00-14:00).
73
 
74
  Args:
75
  task: The task to check.
 
80
  start_slot_in_day = get_slot_within_day(task.start_slot)
81
  end_slot_in_day = start_slot_in_day + task.duration_slots - 1
82
 
83
+ # Check if task overlaps with lunch break slots (8-9, which is 13:00-14:00)
84
+ return (
85
+ start_slot_in_day <= LUNCH_BREAK_END_SLOT - 1
86
+ and end_slot_in_day >= LUNCH_BREAK_START_SLOT
87
+ )
88
+
89
+
90
+ def is_weekend_slot(slot: int) -> bool:
91
+ """Check if a slot falls on a weekend.
92
+
93
+ Args:
94
+ slot: The slot index
95
+
96
+ Returns:
97
+ bool: True if the slot is on a weekend
98
+ """
99
+ working_day = get_working_day_from_slot(slot)
100
+ # For simplicity, assume every 7th day starting from day 5 and 6 are weekends
101
+ # This is a simplification - in practice you'd want to use actual calendar logic
102
+ day_of_week = working_day % 7
103
+ return day_of_week >= 5 # Saturday (5) and Sunday (6)
104
+
105
+
106
+ def get_slot_date(slot: int, base_date: date = None) -> date:
107
+ """Get the date for a given slot.
108
+
109
+ Args:
110
+ slot: The slot index
111
+ base_date: Base date for slot 0 (defaults to today)
112
+
113
+ Returns:
114
+ date: The date for this slot
115
+ """
116
+ if base_date is None:
117
+ base_date = date.today()
118
+
119
+ working_days = get_working_day_from_slot(slot)
120
+ return base_date + timedelta(days=working_days)
src/factory/data/formatters.py CHANGED
@@ -5,45 +5,10 @@ from factory.data.generators import earliest_monday_on_or_after
5
  from constraint_solvers.timetable.working_hours import (
6
  SLOTS_PER_WORKING_DAY,
7
  MORNING_SLOTS,
 
8
  )
9
 
10
 
11
- def slot_to_datetime(slot: int, base_date: date = None) -> datetime:
12
- """Convert a slot index to actual datetime, respecting working hours.
13
-
14
- Args:
15
- slot (int): The slot index (0-based).
16
- base_date (date, optional): Base date to start from. Defaults to today.
17
-
18
- Returns:
19
- datetime: The actual datetime for this slot.
20
- """
21
- if base_date is None:
22
- base_date = date.today()
23
-
24
- # Calculate which working day this slot falls on
25
- working_day = slot // SLOTS_PER_WORKING_DAY
26
- slot_within_day = slot % SLOTS_PER_WORKING_DAY
27
-
28
- # Calculate the actual calendar date
29
- actual_date = base_date + timedelta(days=working_day)
30
-
31
- # Convert slot within day to actual time
32
- if slot_within_day < MORNING_SLOTS:
33
- # Morning session: 9:00-13:00 (slots 0-7)
34
- hour = 9 + (slot_within_day // 2)
35
- minute = (slot_within_day % 2) * 30
36
- else:
37
- # Afternoon session: 14:00-18:00 (slots 8-15)
38
- afternoon_slot = slot_within_day - MORNING_SLOTS
39
- hour = 14 + (afternoon_slot // 2)
40
- minute = (afternoon_slot % 2) * 30
41
-
42
- return datetime.combine(
43
- actual_date, datetime.min.time().replace(hour=hour, minute=minute)
44
- )
45
-
46
-
47
  def schedule_to_dataframe(schedule) -> pd.DataFrame:
48
  """
49
  Convert an EmployeeSchedule to a pandas DataFrame.
@@ -56,14 +21,22 @@ def schedule_to_dataframe(schedule) -> pd.DataFrame:
56
  """
57
  data: list[dict[str, str]] = []
58
 
 
 
 
 
 
 
59
  # Process each task in the schedule
60
  for task in schedule.tasks:
61
  # Get employee name or "Unassigned" if no employee assigned
62
  employee: str = task.employee.name if task.employee else "Unassigned"
63
 
64
- # Calculate start and end times using working hours
65
- start_time: datetime = slot_to_datetime(task.start_slot)
66
- end_time: datetime = slot_to_datetime(task.start_slot + task.duration_slots)
 
 
67
 
68
  # Add task data to list with availability flags
69
  data.append(
@@ -76,6 +49,7 @@ def schedule_to_dataframe(schedule) -> pd.DataFrame:
76
  "End": end_time,
77
  "Duration (hours)": task.duration_slots / 2, # Convert slots to hours
78
  "Required Skill": task.required_skill,
 
79
  # Check if task falls on employee's unavailable date
80
  "Unavailable": employee != "Unassigned"
81
  and hasattr(task.employee, "unavailable_dates")
 
5
  from constraint_solvers.timetable.working_hours import (
6
  SLOTS_PER_WORKING_DAY,
7
  MORNING_SLOTS,
8
+ slot_to_datetime,
9
  )
10
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def schedule_to_dataframe(schedule) -> pd.DataFrame:
13
  """
14
  Convert an EmployeeSchedule to a pandas DataFrame.
 
21
  """
22
  data: list[dict[str, str]] = []
23
 
24
+ # Get base date from schedule info if available
25
+ base_date = None
26
+ if hasattr(schedule, "schedule_info"):
27
+ if hasattr(schedule.schedule_info, "base_date"):
28
+ base_date = schedule.schedule_info.base_date
29
+
30
  # Process each task in the schedule
31
  for task in schedule.tasks:
32
  # Get employee name or "Unassigned" if no employee assigned
33
  employee: str = task.employee.name if task.employee else "Unassigned"
34
 
35
+ # Calculate start and end times (naive local time)
36
+ start_time: datetime = slot_to_datetime(task.start_slot, base_date)
37
+ end_time: datetime = slot_to_datetime(
38
+ task.start_slot + task.duration_slots, base_date
39
+ )
40
 
41
  # Add task data to list with availability flags
42
  data.append(
 
49
  "End": end_time,
50
  "Duration (hours)": task.duration_slots / 2, # Convert slots to hours
51
  "Required Skill": task.required_skill,
52
+ "Pinned": getattr(task, "pinned", False), # Include pinned status
53
  # Check if task falls on employee's unavailable date
54
  "Unavailable": employee != "Unassigned"
55
  and hasattr(task.employee, "unavailable_dates")
src/factory/data/generators.py CHANGED
@@ -5,6 +5,7 @@ from itertools import product
5
 
6
  from factory.data.models import *
7
  from constraint_solvers.timetable.domain import *
 
8
 
9
 
10
  ### EMPLOYEES ###
@@ -209,9 +210,11 @@ def generate_tasks_from_calendar(
209
  parameters: TimeTableDataParameters,
210
  random: Random,
211
  calendar_entries: list[dict],
 
212
  ) -> list[Task]:
213
  """
214
  Generate Task objects from calendar entries with Skills.
 
215
  """
216
  tasks: list[Task] = []
217
  ids = generate_task_ids()
@@ -225,13 +228,27 @@ def generate_tasks_from_calendar(
225
  else:
226
  required_skill = random.choice(parameters.skill_set.optional_skills)
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  tasks.append(
229
  Task(
230
  id=next(ids),
231
  description=entry["summary"],
232
- duration_slots=entry.get("duration_slots", 2), # Default 1 hour
233
- start_slot=entry.get("start_slot", 0),
234
  required_skill=required_skill,
 
235
  )
236
  )
237
 
 
5
 
6
  from factory.data.models import *
7
  from constraint_solvers.timetable.domain import *
8
+ from utils.extract_calendar import datetime_to_slot, calculate_duration_slots
9
 
10
 
11
  ### EMPLOYEES ###
 
210
  parameters: TimeTableDataParameters,
211
  random: Random,
212
  calendar_entries: list[dict],
213
+ base_date: date = None,
214
  ) -> list[Task]:
215
  """
216
  Generate Task objects from calendar entries with Skills.
217
+ Calendar tasks are pinned to their original datetime slots.
218
  """
219
  tasks: list[Task] = []
220
  ids = generate_task_ids()
 
228
  else:
229
  required_skill = random.choice(parameters.skill_set.optional_skills)
230
 
231
+ # Calculate start_slot and duration_slots from calendar datetime info
232
+ start_datetime = entry.get("start_datetime")
233
+ end_datetime = entry.get("end_datetime")
234
+
235
+ if start_datetime and end_datetime and base_date:
236
+ # Calculate actual slot and duration from calendar times
237
+ start_slot = datetime_to_slot(start_datetime, base_date)
238
+ duration_slots = calculate_duration_slots(start_datetime, end_datetime)
239
+ else:
240
+ # Fallback to default values if datetime info is missing
241
+ start_slot = entry.get("start_slot", 0)
242
+ duration_slots = entry.get("duration_slots", 2) # Default 1 hour
243
+
244
  tasks.append(
245
  Task(
246
  id=next(ids),
247
  description=entry["summary"],
248
+ duration_slots=duration_slots,
249
+ start_slot=start_slot,
250
  required_skill=required_skill,
251
+ pinned=True, # Pin calendar tasks to their original times
252
  )
253
  )
254
 
src/factory/data/provider.py CHANGED
@@ -15,6 +15,11 @@ from factory.agents.task_composer_agent import TaskComposerAgent
15
  from constraint_solvers.timetable.domain import *
16
 
17
  from utils.logging_config import setup_logging, get_logger
 
 
 
 
 
18
 
19
  # Initialize logging
20
  setup_logging()
@@ -141,12 +146,65 @@ async def generate_mcp_data(
141
  days_in_schedule: int = None,
142
  ):
143
  parameters = MCP_PARAMS
144
- if employee_count is not None or days_in_schedule is not None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  parameters = TimeTableDataParameters(
146
  skill_set=parameters.skill_set,
147
- days_in_schedule=days_in_schedule
148
- if days_in_schedule is not None
149
- else parameters.days_in_schedule,
150
  employee_count=employee_count
151
  if employee_count is not None
152
  else parameters.employee_count,
@@ -155,14 +213,25 @@ async def generate_mcp_data(
155
  random_seed=parameters.random_seed,
156
  )
157
 
158
- start_date: date = earliest_monday_on_or_after(date.today())
159
  randomizer: Random = Random(parameters.random_seed)
160
  total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
161
 
162
  # --- CALENDAR TASKS ---
163
  calendar_tasks = generate_tasks_from_calendar(
164
- parameters, randomizer, calendar_entries
165
  )
 
 
 
 
 
 
 
 
 
 
 
 
166
  # Assign project_id 'EXISTING' to all calendar tasks
167
  for t in calendar_tasks:
168
  t.sequence_number = 0 # will be overwritten later
@@ -257,7 +326,9 @@ async def generate_mcp_data(
257
  schedule = EmployeeSchedule(
258
  employees=employees,
259
  tasks=all_tasks,
260
- schedule_info=ScheduleInfo(total_slots=total_slots),
 
 
261
  )
262
 
263
  final_df = schedule_to_dataframe(schedule)
 
15
  from constraint_solvers.timetable.domain import *
16
 
17
  from utils.logging_config import setup_logging, get_logger
18
+ from utils.extract_calendar import (
19
+ get_earliest_calendar_date,
20
+ datetime_to_slot,
21
+ validate_calendar_working_hours,
22
+ )
23
 
24
  # Initialize logging
25
  setup_logging()
 
146
  days_in_schedule: int = None,
147
  ):
148
  parameters = MCP_PARAMS
149
+
150
+ # --- DETERMINE START DATE AND REQUIRED SCHEDULE LENGTH FROM CALENDAR ---
151
+
152
+ # Validate calendar entries are within working hours first
153
+ if calendar_entries:
154
+ is_valid, error_msg = validate_calendar_working_hours(calendar_entries)
155
+ if not is_valid:
156
+ logger.error(f"❌ Calendar validation failed: {error_msg}")
157
+ raise ValueError(
158
+ f"Calendar entries violate working hours constraints:\n{error_msg}"
159
+ )
160
+ else:
161
+ logger.info(
162
+ f"βœ… All {len(calendar_entries)} calendar entries are within working hours (8:00-18:00)"
163
+ )
164
+
165
+ # Use earliest calendar date as the base, or fall back to next Monday if no calendar
166
+ earliest_calendar_date = (
167
+ get_earliest_calendar_date(calendar_entries) if calendar_entries else None
168
+ )
169
+
170
+ if earliest_calendar_date:
171
+ start_date: date = earliest_calendar_date
172
+
173
+ # Calculate required schedule length to accommodate all calendar entries
174
+ if calendar_entries and days_in_schedule is None:
175
+ # Find the latest calendar date to determine required schedule length
176
+ latest_date = earliest_calendar_date
177
+ for entry in calendar_entries:
178
+ end_dt = entry.get("end_datetime")
179
+ if end_dt and end_dt.date() > latest_date:
180
+ latest_date = end_dt.date()
181
+
182
+ # Calculate days needed plus buffer for LLM tasks
183
+ calendar_days_span = (latest_date - earliest_calendar_date).days + 1
184
+ min_required_days = (
185
+ calendar_days_span + 30
186
+ ) # Add 30 days buffer for LLM tasks
187
+
188
+ # Use the larger of user-specified or calculated requirement
189
+ calculated_days = max(min_required_days, parameters.days_in_schedule)
190
+ logger.info(
191
+ f"πŸ“Š Calendar span: {calendar_days_span} days, using {calculated_days} total schedule days"
192
+ )
193
+ else:
194
+ calculated_days = (
195
+ days_in_schedule if days_in_schedule else parameters.days_in_schedule
196
+ )
197
+ else:
198
+ start_date: date = earliest_monday_on_or_after(date.today())
199
+ calculated_days = (
200
+ days_in_schedule if days_in_schedule else parameters.days_in_schedule
201
+ )
202
+
203
+ # Update parameters with calculated values
204
+ if employee_count is not None or calculated_days != parameters.days_in_schedule:
205
  parameters = TimeTableDataParameters(
206
  skill_set=parameters.skill_set,
207
+ days_in_schedule=calculated_days,
 
 
208
  employee_count=employee_count
209
  if employee_count is not None
210
  else parameters.employee_count,
 
213
  random_seed=parameters.random_seed,
214
  )
215
 
 
216
  randomizer: Random = Random(parameters.random_seed)
217
  total_slots: int = parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
218
 
219
  # --- CALENDAR TASKS ---
220
  calendar_tasks = generate_tasks_from_calendar(
221
+ parameters, randomizer, calendar_entries, base_date=start_date
222
  )
223
+
224
+ # Validate that all calendar tasks have valid slot assignments
225
+ for task in calendar_tasks:
226
+ if task.start_slot >= total_slots:
227
+ logger.error(
228
+ f"Calendar task '{task.description}' has slot {task.start_slot} >= {total_slots}"
229
+ )
230
+ raise ValueError(
231
+ f"Calendar task slot {task.start_slot} exceeds schedule length {total_slots}. "
232
+ f"Increase days_in_schedule or check calendar dates."
233
+ )
234
+
235
  # Assign project_id 'EXISTING' to all calendar tasks
236
  for t in calendar_tasks:
237
  t.sequence_number = 0 # will be overwritten later
 
326
  schedule = EmployeeSchedule(
327
  employees=employees,
328
  tasks=all_tasks,
329
+ schedule_info=ScheduleInfo(
330
+ total_slots=total_slots, base_date=start_date, base_timezone=None
331
+ ),
332
  )
333
 
334
  final_df = schedule_to_dataframe(schedule)
src/services/data.py CHANGED
@@ -2,6 +2,7 @@ import os
2
  import uuid
3
  from io import StringIO
4
  from typing import Dict, List, Tuple, Union, Optional, Any
 
5
 
6
  import pandas as pd
7
 
@@ -22,6 +23,7 @@ from constraint_solvers.timetable.domain import (
22
  from factory.data.formatters import schedule_to_dataframe, employees_to_dataframe
23
  from .mock_projects import MockProjectService
24
  from utils.logging_config import setup_logging, get_logger
 
25
 
26
  # Initialize logging
27
  setup_logging()
@@ -214,7 +216,8 @@ class DataService:
214
  employees=list(combined_employees.values()),
215
  tasks=combined_tasks,
216
  schedule_info=ScheduleInfo(
217
- total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
 
218
  ),
219
  )
220
 
@@ -238,6 +241,7 @@ class DataService:
238
  "End",
239
  "Duration (hours)",
240
  "Required Skill",
 
241
  ]
242
  ].sort_values(["Project", "Sequence"])
243
 
@@ -289,12 +293,15 @@ class DataService:
289
  raise ValueError(f"Error parsing task data: {str(e)}")
290
 
291
  @staticmethod
292
- def convert_dataframe_to_tasks(task_df: pd.DataFrame) -> List[Task]:
 
 
293
  """
294
  Convert a DataFrame to a list of Task objects.
295
 
296
  Args:
297
  task_df: DataFrame containing task data
 
298
 
299
  Returns:
300
  List of Task objects
@@ -302,19 +309,131 @@ class DataService:
302
  logger.info("πŸ†” Generating task IDs and converting to solver format...")
303
  ids = (str(i) for i in range(len(task_df)))
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  tasks = []
306
  for _, row in task_df.iterrows():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  tasks.append(
308
  Task(
309
  id=next(ids),
310
  description=row["Task"],
311
  duration_slots=int(float(row["Duration (hours)"]) * 2),
312
- start_slot=0,
313
  required_skill=row["Required Skill"],
314
  project_id=row.get("Project", ""),
315
  sequence_number=int(row.get("Sequence", 0)),
 
 
316
  )
317
  )
318
 
319
- logger.info(f"βœ… Converted {len(tasks)} tasks for solver")
 
 
320
  return tasks
 
2
  import uuid
3
  from io import StringIO
4
  from typing import Dict, List, Tuple, Union, Optional, Any
5
+ from datetime import datetime, date, timezone
6
 
7
  import pandas as pd
8
 
 
23
  from factory.data.formatters import schedule_to_dataframe, employees_to_dataframe
24
  from .mock_projects import MockProjectService
25
  from utils.logging_config import setup_logging, get_logger
26
+ from utils.extract_calendar import datetime_to_slot, get_earliest_calendar_date
27
 
28
  # Initialize logging
29
  setup_logging()
 
216
  employees=list(combined_employees.values()),
217
  tasks=combined_tasks,
218
  schedule_info=ScheduleInfo(
219
+ total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY,
220
+ base_date=None, # Use default base_date for regular data loading
221
  ),
222
  )
223
 
 
241
  "End",
242
  "Duration (hours)",
243
  "Required Skill",
244
+ "Pinned",
245
  ]
246
  ].sort_values(["Project", "Sequence"])
247
 
 
293
  raise ValueError(f"Error parsing task data: {str(e)}")
294
 
295
  @staticmethod
296
+ def convert_dataframe_to_tasks(
297
+ task_df: pd.DataFrame, base_date: date = None
298
+ ) -> List[Task]:
299
  """
300
  Convert a DataFrame to a list of Task objects.
301
 
302
  Args:
303
  task_df: DataFrame containing task data
304
+ base_date: Base date for slot calculations (for pinned tasks)
305
 
306
  Returns:
307
  List of Task objects
 
309
  logger.info("πŸ†” Generating task IDs and converting to solver format...")
310
  ids = (str(i) for i in range(len(task_df)))
311
 
312
+ # Determine base_date if not provided
313
+ if base_date is None:
314
+ # Try to get from pinned tasks' dates
315
+ pinned_tasks = task_df[task_df.get("Pinned", False) == True]
316
+ if not pinned_tasks.empty:
317
+ earliest_date = None
318
+ for _, row in pinned_tasks.iterrows():
319
+ start_time = row.get("Start")
320
+ if start_time is not None:
321
+ try:
322
+ if isinstance(start_time, str):
323
+ dt = datetime.fromisoformat(
324
+ start_time.replace("Z", "+00:00")
325
+ )
326
+ elif isinstance(start_time, pd.Timestamp):
327
+ dt = start_time.to_pydatetime()
328
+ elif isinstance(start_time, datetime):
329
+ dt = start_time
330
+ elif isinstance(start_time, (int, float)):
331
+ # Handle Unix timestamp (milliseconds or seconds)
332
+ if start_time > 1e10:
333
+ dt = datetime.fromtimestamp(
334
+ start_time / 1000, tz=timezone.utc
335
+ ).replace(tzinfo=None)
336
+ else:
337
+ dt = datetime.fromtimestamp(
338
+ start_time, tz=timezone.utc
339
+ ).replace(tzinfo=None)
340
+ else:
341
+ logger.debug(
342
+ f"Unhandled start_time type for base_date: {type(start_time)} = {start_time}"
343
+ )
344
+ continue
345
+
346
+ if earliest_date is None or dt.date() < earliest_date:
347
+ earliest_date = dt.date()
348
+ except Exception as e:
349
+ logger.debug(f"Error parsing start_time for base_date: {e}")
350
+ continue
351
+
352
+ if earliest_date:
353
+ base_date = earliest_date
354
+ logger.info(f"Determined base_date from pinned tasks: {base_date}")
355
+ else:
356
+ base_date = date.today()
357
+ logger.warning(
358
+ "Could not determine base_date from pinned tasks, using today"
359
+ )
360
+ else:
361
+ base_date = date.today()
362
+
363
  tasks = []
364
  for _, row in task_df.iterrows():
365
+ # Check if task is pinned and should preserve its start_slot
366
+ is_pinned = row.get("Pinned", False)
367
+
368
+ # For pinned tasks, calculate start_slot from the Start datetime
369
+ if is_pinned and "Start" in row and row["Start"] is not None:
370
+ try:
371
+ start_time = row["Start"]
372
+
373
+ # Handle different datetime formats
374
+ if isinstance(start_time, str):
375
+ # Parse ISO string
376
+ start_time = datetime.fromisoformat(
377
+ start_time.replace("Z", "+00:00")
378
+ )
379
+ elif isinstance(start_time, pd.Timestamp):
380
+ # Convert pandas Timestamp to datetime
381
+ start_time = start_time.to_pydatetime()
382
+ elif isinstance(start_time, (int, float)):
383
+ # Handle Unix timestamp (milliseconds or seconds)
384
+ try:
385
+ # If it's a large number, assume milliseconds
386
+ if start_time > 1e10:
387
+ start_time = datetime.fromtimestamp(
388
+ start_time / 1000, tz=timezone.utc
389
+ ).replace(tzinfo=None)
390
+ else:
391
+ start_time = datetime.fromtimestamp(
392
+ start_time, tz=timezone.utc
393
+ ).replace(tzinfo=None)
394
+ except (ValueError, OSError) as e:
395
+ logger.warning(
396
+ f"Cannot convert timestamp {start_time} to datetime: {e}"
397
+ )
398
+ start_slot = 0
399
+ elif not isinstance(start_time, datetime):
400
+ # Skip conversion if we can't parse the datetime
401
+ logger.warning(
402
+ f"Cannot parse start time for pinned task: {start_time} (type: {type(start_time)})"
403
+ )
404
+ start_slot = 0
405
+
406
+ if isinstance(start_time, datetime):
407
+ start_slot = datetime_to_slot(start_time, base_date)
408
+ logger.info(
409
+ f"Converted datetime {start_time} to slot {start_slot} for pinned task (base: {base_date})"
410
+ )
411
+ else:
412
+ start_slot = 0
413
+
414
+ except Exception as e:
415
+ logger.warning(
416
+ f"Error converting datetime to slot for pinned task: {e}"
417
+ )
418
+ start_slot = 0
419
+ else:
420
+ start_slot = 0 # Will be assigned by solver for non-pinned tasks
421
+
422
  tasks.append(
423
  Task(
424
  id=next(ids),
425
  description=row["Task"],
426
  duration_slots=int(float(row["Duration (hours)"]) * 2),
427
+ start_slot=start_slot,
428
  required_skill=row["Required Skill"],
429
  project_id=row.get("Project", ""),
430
  sequence_number=int(row.get("Sequence", 0)),
431
+ pinned=is_pinned,
432
+ employee=None, # Will be assigned in generate_schedule_for_solving
433
  )
434
  )
435
 
436
+ logger.info(
437
+ f"βœ… Converted {len(tasks)} tasks for solver (base_date: {base_date})"
438
+ )
439
  return tasks
src/services/schedule.py CHANGED
@@ -1,5 +1,5 @@
1
  import os, uuid, random
2
- from datetime import datetime
3
  from typing import Tuple, Dict, Any, Optional
4
 
5
  import pandas as pd
@@ -82,8 +82,52 @@ class ScheduleService:
82
  # Parse task data
83
  task_df = DataService.parse_task_data_from_json(task_df_json, debug)
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  # Convert DataFrame to tasks
86
- tasks = DataService.convert_dataframe_to_tasks(task_df)
87
 
88
  # Debug: Log task information if debug is enabled
89
  if debug:
@@ -97,7 +141,7 @@ class ScheduleService:
97
 
98
  # Generate schedule
99
  schedule = ScheduleService.generate_schedule_for_solving(
100
- tasks, employee_count, days_in_schedule
101
  )
102
 
103
  # Start solving
@@ -106,7 +150,7 @@ class ScheduleService:
106
  solved_task_df,
107
  new_job_id,
108
  status,
109
- ) = await ScheduleService.solve_schedule(schedule, debug)
110
 
111
  logger.info("πŸ“ˆ Solver process initiated successfully")
112
  return emp_df, solved_task_df, new_job_id, status, state_data
@@ -124,7 +168,10 @@ class ScheduleService:
124
 
125
  @staticmethod
126
  def generate_schedule_for_solving(
127
- tasks: list, employee_count: Optional[int], days_in_schedule: Optional[int]
 
 
 
128
  ) -> EmployeeSchedule:
129
  """Generate a complete schedule ready for solving"""
130
  parameters: TimeTableDataParameters = DATA_PARAMS
@@ -177,16 +224,45 @@ class ScheduleService:
177
 
178
  logger.info(f"βœ… Generated {len(employees)} employees")
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  return EmployeeSchedule(
181
  employees=employees,
182
  tasks=tasks,
183
  schedule_info=ScheduleInfo(
184
- total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY
 
185
  ),
186
  )
187
 
188
  @staticmethod
189
- async def solve_schedule(
190
  schedule: EmployeeSchedule, debug: bool = False
191
  ) -> Tuple[pd.DataFrame, pd.DataFrame, str, str]:
192
  """
@@ -223,6 +299,7 @@ class ScheduleService:
223
  "End",
224
  "Duration (hours)",
225
  "Required Skill",
 
226
  ]
227
  ].sort_values(["Project", "Sequence"])
228
 
@@ -268,6 +345,7 @@ class ScheduleService:
268
  "End",
269
  "Duration (hours)",
270
  "Required Skill",
 
271
  ]
272
  ].sort_values(["Start"])
273
 
 
1
  import os, uuid, random
2
+ from datetime import datetime, date, timezone
3
  from typing import Tuple, Dict, Any, Optional
4
 
5
  import pandas as pd
 
82
  # Parse task data
83
  task_df = DataService.parse_task_data_from_json(task_df_json, debug)
84
 
85
+ # Extract base_date from pinned tasks for consistent slot calculations
86
+ base_date = None
87
+ pinned_tasks = task_df[task_df.get("Pinned", False) == True]
88
+ if not pinned_tasks.empty:
89
+ # Try to determine base_date from earliest pinned task
90
+ earliest_date = None
91
+ for _, row in pinned_tasks.iterrows():
92
+ start_time = row.get("Start")
93
+ if start_time is not None:
94
+ try:
95
+ if isinstance(start_time, str):
96
+ dt = datetime.fromisoformat(
97
+ start_time.replace("Z", "+00:00")
98
+ )
99
+ elif isinstance(start_time, pd.Timestamp):
100
+ dt = start_time.to_pydatetime()
101
+ elif isinstance(start_time, datetime):
102
+ dt = start_time
103
+ elif isinstance(start_time, (int, float)):
104
+ # Handle Unix timestamp (milliseconds or seconds)
105
+ if start_time > 1e10:
106
+ dt = datetime.fromtimestamp(
107
+ start_time / 1000, tz=timezone.utc
108
+ ).replace(tzinfo=None)
109
+ else:
110
+ dt = datetime.fromtimestamp(
111
+ start_time, tz=timezone.utc
112
+ ).replace(tzinfo=None)
113
+ else:
114
+ logger.debug(
115
+ f"Unhandled start_time type for base_date: {type(start_time)} = {start_time}"
116
+ )
117
+ continue
118
+
119
+ if earliest_date is None or dt.date() < earliest_date:
120
+ earliest_date = dt.date()
121
+ except Exception as e:
122
+ logger.debug(f"Error parsing start_time for base_date: {e}")
123
+ continue
124
+
125
+ if earliest_date:
126
+ base_date = earliest_date
127
+ logger.info(f"πŸ—“οΈ Determined base_date for schedule: {base_date}")
128
+
129
  # Convert DataFrame to tasks
130
+ tasks = DataService.convert_dataframe_to_tasks(task_df, base_date)
131
 
132
  # Debug: Log task information if debug is enabled
133
  if debug:
 
141
 
142
  # Generate schedule
143
  schedule = ScheduleService.generate_schedule_for_solving(
144
+ tasks, employee_count, days_in_schedule, base_date
145
  )
146
 
147
  # Start solving
 
150
  solved_task_df,
151
  new_job_id,
152
  status,
153
+ ) = ScheduleService.solve_schedule(schedule, debug)
154
 
155
  logger.info("πŸ“ˆ Solver process initiated successfully")
156
  return emp_df, solved_task_df, new_job_id, status, state_data
 
168
 
169
  @staticmethod
170
  def generate_schedule_for_solving(
171
+ tasks: list,
172
+ employee_count: Optional[int],
173
+ days_in_schedule: Optional[int],
174
+ base_date: date = None,
175
  ) -> EmployeeSchedule:
176
  """Generate a complete schedule ready for solving"""
177
  parameters: TimeTableDataParameters = DATA_PARAMS
 
224
 
225
  logger.info(f"βœ… Generated {len(employees)} employees")
226
 
227
+ # Assign employees to all tasks (both pinned and non-pinned)
228
+ # For single employee scenarios, assign the single employee to all tasks
229
+ if parameters.employee_count == 1 and len(employees) == 1:
230
+ main_employee = employees[0]
231
+ for task in tasks:
232
+ task.employee = main_employee
233
+ logger.debug(
234
+ f"Assigned {main_employee.name} to task: {task.description[:30]}..."
235
+ )
236
+ else:
237
+ # For multi-employee scenarios, assign employees based on skills and availability
238
+ # This is a simple assignment - the solver will optimize later
239
+ for task in tasks:
240
+ # Find an employee with the required skill
241
+ suitable_employees = [
242
+ emp for emp in employees if task.required_skill in emp.skills
243
+ ]
244
+ if suitable_employees:
245
+ task.employee = suitable_employees[0] # Simple assignment
246
+ else:
247
+ # Fallback: assign the first employee
248
+ task.employee = employees[0]
249
+ logger.warning(
250
+ f"No employee found with skill '{task.required_skill}' for task '{task.description[:30]}...', assigned {employees[0].name}"
251
+ )
252
+
253
+ logger.info(f"βœ… Assigned employees to {len(tasks)} tasks")
254
+
255
  return EmployeeSchedule(
256
  employees=employees,
257
  tasks=tasks,
258
  schedule_info=ScheduleInfo(
259
+ total_slots=parameters.days_in_schedule * SLOTS_PER_WORKING_DAY,
260
+ base_date=base_date,
261
  ),
262
  )
263
 
264
  @staticmethod
265
+ def solve_schedule(
266
  schedule: EmployeeSchedule, debug: bool = False
267
  ) -> Tuple[pd.DataFrame, pd.DataFrame, str, str]:
268
  """
 
299
  "End",
300
  "Duration (hours)",
301
  "Required Skill",
302
+ "Pinned",
303
  ]
304
  ].sort_values(["Project", "Sequence"])
305
 
 
345
  "End",
346
  "Duration (hours)",
347
  "Required Skill",
348
+ "Pinned",
349
  ]
350
  ].sort_values(["Start"])
351
 
src/utils/extract_calendar.py CHANGED
@@ -1,4 +1,10 @@
1
  from icalendar import Calendar
 
 
 
 
 
 
2
 
3
 
4
  def extract_ical_entries(file_bytes):
@@ -23,15 +29,178 @@ def extract_ical_entries(file_bytes):
23
 
24
  return str(val)
25
 
26
- entries.append(
27
- {
28
- "summary": summary,
29
- "dtstart": to_iso(dtstart),
30
- "dtend": to_iso(dtend),
31
- }
32
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  return entries, None
35
 
36
  except Exception as e:
37
  return None, str(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from icalendar import Calendar
2
+ from datetime import datetime, date, timezone, timedelta
3
+ from typing import Optional, Tuple, List, Dict, Any
4
+ from constraint_solvers.timetable.working_hours import (
5
+ SLOTS_PER_WORKING_DAY,
6
+ MORNING_SLOTS,
7
+ )
8
 
9
 
10
  def extract_ical_entries(file_bytes):
 
29
 
30
  return str(val)
31
 
32
+ def to_datetime(val):
33
+ """Convert icalendar datetime to Python datetime object, normalized to current timezone."""
34
+ if hasattr(val, "dt"):
35
+ dt = val.dt
36
+ if isinstance(dt, datetime):
37
+ # If timezone-aware, convert to current timezone, then make naive
38
+ if dt.tzinfo is not None:
39
+ # Convert to local timezone then strip timezone info
40
+ local_dt = dt.astimezone()
41
+ return local_dt.replace(tzinfo=None)
42
+ else:
43
+ # Already naive, return as-is
44
+ return dt
45
+ elif isinstance(dt, date):
46
+ # Convert date to datetime at 9 AM (naive)
47
+ return datetime.combine(
48
+ dt, datetime.min.time().replace(hour=9)
49
+ )
50
+ return None
51
+
52
+ # Parse datetime objects for slot calculation (now normalized to current timezone)
53
+ start_datetime = to_datetime(dtstart)
54
+ end_datetime = to_datetime(dtend)
55
+
56
+ entry = {
57
+ "summary": summary,
58
+ "dtstart": to_iso(dtstart),
59
+ "dtend": to_iso(dtend),
60
+ }
61
+
62
+ # Add datetime objects for slot calculation
63
+ if start_datetime:
64
+ entry["start_datetime"] = start_datetime
65
+ if end_datetime:
66
+ entry["end_datetime"] = end_datetime
67
+
68
+ entries.append(entry)
69
 
70
  return entries, None
71
 
72
  except Exception as e:
73
  return None, str(e)
74
+
75
+
76
+ def get_earliest_calendar_date(
77
+ calendar_entries: List[Dict[str, Any]]
78
+ ) -> Optional[date]:
79
+ """
80
+ Find the earliest date from calendar entries to use as base_date for scheduling.
81
+
82
+ Args:
83
+ calendar_entries: List of calendar entry dictionaries
84
+
85
+ Returns:
86
+ The earliest date found, or None if no valid dates found
87
+ """
88
+ earliest_date = None
89
+
90
+ for entry in calendar_entries:
91
+ start_datetime = entry.get("start_datetime")
92
+ if start_datetime and isinstance(start_datetime, datetime):
93
+ entry_date = start_datetime.date()
94
+ if earliest_date is None or entry_date < earliest_date:
95
+ earliest_date = entry_date
96
+
97
+ return earliest_date
98
+
99
+
100
+ def validate_calendar_working_hours(
101
+ calendar_entries: List[Dict[str, Any]]
102
+ ) -> Tuple[bool, str]:
103
+ """
104
+ Validate that all calendar entries fall within standard working hours (9:00-18:00) and don't span lunch break (13:00-14:00).
105
+
106
+ Args:
107
+ calendar_entries: List of calendar entry dictionaries
108
+
109
+ Returns:
110
+ Tuple of (is_valid, error_message)
111
+ """
112
+ if not calendar_entries:
113
+ return True, ""
114
+
115
+ violations = []
116
+
117
+ for entry in calendar_entries:
118
+ summary = entry.get("summary", "Unknown Event")
119
+ start_datetime = entry.get("start_datetime")
120
+ end_datetime = entry.get("end_datetime")
121
+
122
+ if start_datetime and isinstance(start_datetime, datetime):
123
+ if start_datetime.hour < 9:
124
+ violations.append(
125
+ f"'{summary}' starts at {start_datetime.hour:02d}:{start_datetime.minute:02d} (before 9:00)"
126
+ )
127
+
128
+ if end_datetime and isinstance(end_datetime, datetime):
129
+ if end_datetime.hour > 18 or (
130
+ end_datetime.hour == 18 and end_datetime.minute > 0
131
+ ):
132
+ violations.append(
133
+ f"'{summary}' ends at {end_datetime.hour:02d}:{end_datetime.minute:02d} (after 18:00)"
134
+ )
135
+
136
+ # Check for lunch break spanning (13:00-14:00)
137
+ if (
138
+ start_datetime
139
+ and end_datetime
140
+ and isinstance(start_datetime, datetime)
141
+ and isinstance(end_datetime, datetime)
142
+ ):
143
+ start_hour_min = start_datetime.hour + start_datetime.minute / 60.0
144
+ end_hour_min = end_datetime.hour + end_datetime.minute / 60.0
145
+
146
+ # Check if task spans across lunch break (13:00-14:00)
147
+ if start_hour_min < 14.0 and end_hour_min > 13.0:
148
+ violations.append(
149
+ f"'{summary}' ({start_datetime.hour:02d}:{start_datetime.minute:02d}-{end_datetime.hour:02d}:{end_datetime.minute:02d}) spans lunch break (13:00-14:00)"
150
+ )
151
+
152
+ if violations:
153
+ error_msg = "Calendar entries violate working constraints:\n" + "\n".join(
154
+ violations
155
+ )
156
+ return False, error_msg
157
+
158
+ return True, ""
159
+
160
+
161
+ def datetime_to_slot(dt: datetime, base_date: date) -> int:
162
+ """
163
+ Convert a datetime to a 30-minute slot index within working days.
164
+
165
+ Args:
166
+ dt: The datetime to convert (should be naive local time)
167
+ base_date: The base date (slot 0 = base_date at 9:00 AM local time)
168
+
169
+ Returns:
170
+ The slot index (each slot = 30 minutes within working hours)
171
+ """
172
+ # Calculate which working day this datetime falls on
173
+ days_from_base = (dt.date() - base_date).days
174
+
175
+ # Calculate time within the working day (minutes from 9:00 AM)
176
+ minutes_from_9am = (dt.hour - 9) * 60 + dt.minute
177
+
178
+ # Convert to slot within the day (each slot = 30 minutes)
179
+ slot_within_day = round(minutes_from_9am / 30)
180
+
181
+ # Calculate total slot index
182
+ total_slot = days_from_base * SLOTS_PER_WORKING_DAY + slot_within_day
183
+
184
+ # Ensure non-negative slot
185
+ return max(0, total_slot)
186
+
187
+
188
+ def calculate_duration_slots(start_dt: datetime, end_dt: datetime) -> int:
189
+ """
190
+ Calculate duration in 30-minute slots between two datetimes (naive local time).
191
+
192
+ Args:
193
+ start_dt: Start datetime (naive local time)
194
+ end_dt: End datetime (naive local time)
195
+
196
+ Returns:
197
+ Duration in 30-minute slots (minimum 1 slot)
198
+ """
199
+ # Calculate difference in minutes (both should be naive local time)
200
+ time_diff = end_dt - start_dt
201
+ total_minutes = time_diff.total_seconds() / 60
202
+
203
+ # Convert to 30-minute slots, rounding up to ensure task duration is preserved
204
+ duration_slots = max(1, round(total_minutes / 30))
205
+
206
+ return duration_slots
tests/data/calendar.ics CHANGED
@@ -15,8 +15,8 @@ END:VEVENT
15
  BEGIN:VEVENT
16
  UID:recur-meeting-2@mock
17
  DTSTAMP:20240523T000000Z
18
- DTSTART;TZID=UTC:20250602T140000
19
- DTEND;TZID=UTC:20250602T150000
20
  RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
21
  SUMMARY:Project Review
22
  END:VEVENT
@@ -24,16 +24,16 @@ END:VEVENT
24
  BEGIN:VEVENT
25
  UID:single-event-1@mock
26
  DTSTAMP:20240523T000000Z
27
- DTSTART;TZID=UTC:20250605T130000
28
- DTEND;TZID=UTC:20250605T140000
29
  SUMMARY:Client Call
30
  END:VEVENT
31
 
32
  BEGIN:VEVENT
33
  UID:single-event-2@mock
34
  DTSTAMP:20240523T000000Z
35
- DTSTART;TZID=UTC:20250616T110000
36
- DTEND;TZID=UTC:20250616T120000
37
  SUMMARY:Workshop
38
  END:VEVENT
39
 
@@ -41,15 +41,15 @@ BEGIN:VEVENT
41
  UID:single-event-3@mock
42
  DTSTAMP:20240523T000000Z
43
  DTSTART;TZID=UTC:20250707T150000
44
- DTEND;TZID=UTC:20250707T163000
45
  SUMMARY:Planning Session
46
  END:VEVENT
47
 
48
  BEGIN:VEVENT
49
  UID:single-event-4@mock
50
  DTSTAMP:20240523T000000Z
51
- DTSTART;TZID=UTC:20250722T093000
52
- DTEND;TZID=UTC:20250722T103000
53
  SUMMARY:Demo
54
  END:VEVENT
55
 
 
15
  BEGIN:VEVENT
16
  UID:recur-meeting-2@mock
17
  DTSTAMP:20240523T000000Z
18
+ DTSTART;TZID=UTC:20250602T143000
19
+ DTEND;TZID=UTC:20250602T153000
20
  RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
21
  SUMMARY:Project Review
22
  END:VEVENT
 
24
  BEGIN:VEVENT
25
  UID:single-event-1@mock
26
  DTSTAMP:20240523T000000Z
27
+ DTSTART;TZID=UTC:20250605T133000
28
+ DTEND;TZID=UTC:20250605T143000
29
  SUMMARY:Client Call
30
  END:VEVENT
31
 
32
  BEGIN:VEVENT
33
  UID:single-event-2@mock
34
  DTSTAMP:20240523T000000Z
35
+ DTSTART;TZID=UTC:20250616T143000
36
+ DTEND;TZID=UTC:20250616T153000
37
  SUMMARY:Workshop
38
  END:VEVENT
39
 
 
41
  UID:single-event-3@mock
42
  DTSTAMP:20240523T000000Z
43
  DTSTART;TZID=UTC:20250707T150000
44
+ DTEND;TZID=UTC:20250707T160000
45
  SUMMARY:Planning Session
46
  END:VEVENT
47
 
48
  BEGIN:VEVENT
49
  UID:single-event-4@mock
50
  DTSTAMP:20240523T000000Z
51
+ DTSTART;TZID=UTC:20250722T100000
52
+ DTEND;TZID=UTC:20250722T110000
53
  SUMMARY:Demo
54
  END:VEVENT
55
 
tests/data/calendar_wrong.ics ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ BEGIN:VCALENDAR
2
+ VERSION:2.0
3
+ PRODID:-//Mock Calendar//EN
4
+ CALSCALE:GREGORIAN
5
+
6
+ BEGIN:VEVENT
7
+ UID:early-meeting@mock
8
+ DTSTAMP:20240523T000000Z
9
+ DTSTART:20250603T050000Z
10
+ DTEND:20250603T060000Z
11
+ SUMMARY:Early Morning Meeting
12
+ END:VEVENT
13
+
14
+ BEGIN:VEVENT
15
+ UID:late-meeting@mock
16
+ DTSTAMP:20240523T000000Z
17
+ DTSTART;TZID=UTC:20250602T180000
18
+ DTEND;TZID=UTC:20250602T190000
19
+ SUMMARY:Evening Meeting
20
+ END:VEVENT
21
+
22
+ BEGIN:VEVENT
23
+ UID:lunch-meeting@mock
24
+ DTSTAMP:20240523T000000Z
25
+ DTSTART;TZID=UTC:20250605T130000
26
+ DTEND;TZID=UTC:20250605T140000
27
+ SUMMARY:Lunch Meeting
28
+ END:VEVENT
29
+
30
+ BEGIN:VEVENT
31
+ UID:long-lunch-meeting@mock
32
+ DTSTAMP:20240523T000000Z
33
+ DTSTART;TZID=UTC:20250616T123000
34
+ DTEND;TZID=UTC:20250616T143000
35
+ SUMMARY:Long Lunch Meeting
36
+ END:VEVENT
37
+
38
+ BEGIN:VEVENT
39
+ UID:very-late-meeting@mock
40
+ DTSTAMP:20240523T000000Z
41
+ DTSTART;TZID=UTC:20250707T190000
42
+ DTEND;TZID=UTC:20250707T200000
43
+ SUMMARY:Very Late Meeting
44
+ END:VEVENT
45
+
46
+ BEGIN:VEVENT
47
+ UID:valid-meeting@mock
48
+ DTSTAMP:20240523T000000Z
49
+ DTSTART;TZID=UTC:20250722T100000
50
+ DTEND;TZID=UTC:20250722T110000
51
+ SUMMARY:Valid Meeting
52
+ END:VEVENT
53
+
54
+ END:VCALENDAR
tests/test_constraints.py CHANGED
@@ -215,8 +215,8 @@ class TestConstraints:
215
  task = create_task(
216
  task_id="task1",
217
  description="Overlong Task",
218
- duration_slots=10, # Task extends to slot 64 (beyond 59)
219
- start_slot=55,
220
  required_skill="Python",
221
  employee=self.employee_alice,
222
  )
@@ -245,11 +245,11 @@ class TestConstraints:
245
 
246
  def test_unavailable_employee_constraint_violation(self):
247
  """Test that tasks assigned to unavailable employees are penalized."""
248
- # Assuming 16 slots per working day, tomorrow starts at slot 16
249
  task = create_task(
250
  task_id="task1",
251
  description="Task on unavailable day",
252
- start_slot=16, # Tomorrow (when Alice is unavailable)
253
  required_skill="Python",
254
  employee=self.employee_alice,
255
  )
@@ -261,11 +261,11 @@ class TestConstraints:
261
  )
262
 
263
  def test_unavailable_employee_constraint_satisfied(self):
264
- """Test that tasks assigned on available days are not penalized."""
265
  task = create_task(
266
  task_id="task1",
267
  description="Task on available day",
268
- start_slot=0, # Today (when Alice is available)
269
  required_skill="Python",
270
  employee=self.employee_alice,
271
  )
@@ -371,8 +371,8 @@ class TestConstraints:
371
  task = create_task(
372
  task_id="task1",
373
  description="Task spanning lunch",
374
- start_slot=6, # Starts in morning (slot 6)
375
- duration_slots=4, # Ends in afternoon (slot 10), spans lunch
376
  required_skill="Python",
377
  employee=self.employee_alice,
378
  )
@@ -383,30 +383,13 @@ class TestConstraints:
383
  .penalizes_by(1)
384
  )
385
 
386
- def test_no_lunch_break_spanning_constraint_satisfied_morning(self):
387
- """Test that tasks contained in morning session are not penalized."""
388
  task = create_task(
389
  task_id="task1",
390
- description="Morning task",
391
- start_slot=2, # Morning session
392
- duration_slots=4, # Stays in morning (slots 2-5)
393
- required_skill="Python",
394
- employee=self.employee_alice,
395
- )
396
-
397
- (
398
- self.constraint_verifier.verify_that(no_lunch_break_spanning)
399
- .given(task, self.employee_alice, self.schedule_info)
400
- .penalizes_by(0)
401
- )
402
-
403
- def test_no_lunch_break_spanning_constraint_satisfied_afternoon(self):
404
- """Test that tasks contained in afternoon session are not penalized."""
405
- task = create_task(
406
- task_id="task1",
407
- description="Afternoon task",
408
- start_slot=10, # Afternoon session (slot 10 = 3rd hour of afternoon)
409
- duration_slots=4, # Stays in afternoon (slots 10-13)
410
  required_skill="Python",
411
  employee=self.employee_alice,
412
  )
@@ -441,11 +424,11 @@ class TestConstraints:
441
 
442
  def test_undesired_day_for_employee_constraint_violation(self):
443
  """Test that tasks on undesired days incur soft penalty."""
444
- # Assuming 16 slots per working day, day after tomorrow starts at slot 32
445
  task = create_task(
446
  task_id="task1",
447
  description="Task on undesired day",
448
- start_slot=32, # Day after tomorrow (Alice's undesired date)
449
  required_skill="Python",
450
  employee=self.employee_alice,
451
  )
@@ -457,11 +440,11 @@ class TestConstraints:
457
  )
458
 
459
  def test_undesired_day_for_employee_constraint_satisfied(self):
460
- """Test that tasks on neutral days don't incur undesired day penalty."""
461
  task = create_task(
462
  task_id="task1",
463
  description="Task on neutral day",
464
- start_slot=0, # Today (neutral for Alice, though it's also desired)
465
  required_skill="Python",
466
  employee=self.employee_alice,
467
  )
@@ -473,7 +456,8 @@ class TestConstraints:
473
  )
474
 
475
  def test_desired_day_for_employee_constraint_reward(self):
476
- """Test that tasks on desired days provide soft reward."""
 
477
  task = create_task(
478
  task_id="task1",
479
  description="Task on desired day",
@@ -493,7 +477,7 @@ class TestConstraints:
493
  task = create_task(
494
  task_id="task1",
495
  description="Task on neutral day",
496
- start_slot=16, # Tomorrow (neutral for Alice)
497
  required_skill="Python",
498
  employee=self.employee_alice,
499
  )
@@ -606,7 +590,7 @@ class TestConstraints:
606
  create_task(
607
  "task2",
608
  "Valid Java Task",
609
- start_slot=8, # Afternoon session, non-overlapping
610
  required_skill="Java",
611
  project_id="project1",
612
  sequence_number=2,
@@ -615,7 +599,7 @@ class TestConstraints:
615
  create_task(
616
  "task3",
617
  "Bob's Valid Task",
618
- start_slot=12,
619
  required_skill="Java",
620
  project_id="project2",
621
  sequence_number=1,
@@ -675,7 +659,7 @@ class TestConstraints:
675
  self.employee_bob,
676
  self.schedule_info,
677
  )
678
- .scores(HardSoftDecimalScore.of(Decimal("-5"), Decimal("-0.12132")))
679
  )
680
 
681
 
@@ -728,11 +712,11 @@ def create_task(
728
  )
729
 
730
 
731
- def create_schedule_info(total_slots=48):
732
  """Create a schedule info object with specified total slots.
733
- Default is 48 slots = 3 working days * 16 slots per working day.
734
  """
735
- return ScheduleInfo(total_slots=total_slots)
736
 
737
 
738
  def create_standard_employees(dates):
 
215
  task = create_task(
216
  task_id="task1",
217
  description="Overlong Task",
218
+ duration_slots=10, # Task extends to slot 65 (beyond 59)
219
+ start_slot=56, # Start at slot 56, end at slot 65 (beyond schedule)
220
  required_skill="Python",
221
  employee=self.employee_alice,
222
  )
 
245
 
246
  def test_unavailable_employee_constraint_violation(self):
247
  """Test that tasks assigned to unavailable employees are penalized."""
248
+ # With 20 slots per working day, tomorrow starts at slot 20
249
  task = create_task(
250
  task_id="task1",
251
  description="Task on unavailable day",
252
+ start_slot=20, # Tomorrow (when Alice is unavailable)
253
  required_skill="Python",
254
  employee=self.employee_alice,
255
  )
 
261
  )
262
 
263
  def test_unavailable_employee_constraint_satisfied(self):
264
+ """Test that tasks not on unavailable days are not penalized."""
265
  task = create_task(
266
  task_id="task1",
267
  description="Task on available day",
268
+ start_slot=0, # Today (Alice is available)
269
  required_skill="Python",
270
  employee=self.employee_alice,
271
  )
 
371
  task = create_task(
372
  task_id="task1",
373
  description="Task spanning lunch",
374
+ start_slot=7, # Starts at 12:30 (slot 7), spans lunch break
375
+ duration_slots=4, # 2 hours, ends at 14:30 (slot 11)
376
  required_skill="Python",
377
  employee=self.employee_alice,
378
  )
 
383
  .penalizes_by(1)
384
  )
385
 
386
+ def test_no_lunch_break_spanning_constraint_satisfied(self):
387
+ """Test that tasks not spanning lunch break are not penalized."""
388
  task = create_task(
389
  task_id="task1",
390
+ description="Task before lunch",
391
+ start_slot=0, # Starts at 9:00
392
+ duration_slots=4, # 2 hours, ends at 11:00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  required_skill="Python",
394
  employee=self.employee_alice,
395
  )
 
424
 
425
  def test_undesired_day_for_employee_constraint_violation(self):
426
  """Test that tasks on undesired days incur soft penalty."""
427
+ # With 20 slots per working day, day after tomorrow starts at slot 40
428
  task = create_task(
429
  task_id="task1",
430
  description="Task on undesired day",
431
+ start_slot=40, # Day after tomorrow (Alice's undesired date)
432
  required_skill="Python",
433
  employee=self.employee_alice,
434
  )
 
440
  )
441
 
442
  def test_undesired_day_for_employee_constraint_satisfied(self):
443
+ """Test that tasks not on undesired days are not penalized."""
444
  task = create_task(
445
  task_id="task1",
446
  description="Task on neutral day",
447
+ start_slot=0, # Today (neutral for Alice)
448
  required_skill="Python",
449
  employee=self.employee_alice,
450
  )
 
456
  )
457
 
458
  def test_desired_day_for_employee_constraint_reward(self):
459
+ """Test that tasks on desired days provide reward."""
460
+ # Alice's desired day is today (slot 0-19)
461
  task = create_task(
462
  task_id="task1",
463
  description="Task on desired day",
 
477
  task = create_task(
478
  task_id="task1",
479
  description="Task on neutral day",
480
+ start_slot=20, # Tomorrow (neutral for Alice)
481
  required_skill="Python",
482
  employee=self.employee_alice,
483
  )
 
590
  create_task(
591
  "task2",
592
  "Valid Java Task",
593
+ start_slot=10, # After lunch break (14:00), non-overlapping
594
  required_skill="Java",
595
  project_id="project1",
596
  sequence_number=2,
 
599
  create_task(
600
  "task3",
601
  "Bob's Valid Task",
602
+ start_slot=14, # After lunch break (14:00)
603
  required_skill="Java",
604
  project_id="project2",
605
  sequence_number=1,
 
659
  self.employee_bob,
660
  self.schedule_info,
661
  )
662
+ .scores(HardSoftDecimalScore.of(Decimal("-4"), Decimal("-0.12132")))
663
  )
664
 
665
 
 
712
  )
713
 
714
 
715
+ def create_schedule_info(total_slots=60):
716
  """Create a schedule info object with specified total slots.
717
+ Default is 60 slots = 3 working days * 20 slots per working day.
718
  """
719
+ return ScheduleInfo(total_slots=total_slots, base_date=date.today())
720
 
721
 
722
  def create_standard_employees(dates):
tests/test_factory.py CHANGED
@@ -1,4 +1,10 @@
1
  import pytest
 
 
 
 
 
 
2
 
3
  from src.utils.load_secrets import load_secrets
4
 
@@ -7,8 +13,422 @@ load_secrets("tests/secrets/creds.py")
7
 
8
  import factory.data.provider as data_provider
9
  from src.utils.extract_calendar import extract_ical_entries
 
 
 
 
10
 
 
 
 
 
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  @pytest.mark.asyncio
13
  async def test_factory_demo_agent():
14
  # Use a simple string as the project description
@@ -44,23 +464,14 @@ async def test_factory_demo_agent():
44
 
45
 
46
  @pytest.mark.asyncio
47
- async def test_factory_mcp():
48
- # Load the real calendar.ics file
49
- with open("tests/data/calendar.ics", "rb") as f:
50
- file_bytes = f.read()
51
- entries, err = extract_ical_entries(file_bytes)
52
- assert err is None
53
- assert entries is not None
54
- assert len(entries) > 0
55
-
56
- print("\nEntries:")
57
- print(entries)
58
 
59
  # Use a made-up user message
60
  user_message = "Create a new AWS VPC."
61
 
62
  # Call generate_mcp_data directly
63
- df = await data_provider.generate_mcp_data(entries, user_message)
64
 
65
  # Assert the DataFrame is not empty
66
  assert df is not None
@@ -68,3 +479,297 @@ async def test_factory_mcp():
68
 
69
  # Print the DataFrame for debug
70
  print(df)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pytest
2
+ import time
3
+ import pandas as pd
4
+ import traceback
5
+ from io import StringIO
6
+ from datetime import datetime, date, timedelta
7
+ from typing import List, Dict, Tuple, Optional, Any
8
 
9
  from src.utils.load_secrets import load_secrets
10
 
 
13
 
14
  import factory.data.provider as data_provider
15
  from src.utils.extract_calendar import extract_ical_entries
16
+ from src.handlers.mcp_backend import process_message_and_attached_file
17
+ from src.services import ScheduleService, StateService
18
+ from src.services.data import DataService
19
+ from src.factory.data.formatters import schedule_to_dataframe
20
 
21
+ # Add cleanup fixture for proper solver shutdown
22
+ @pytest.fixture(scope="session", autouse=True)
23
+ def cleanup_solver():
24
+ """Automatically cleanup solver resources after all tests complete."""
25
+ yield # Run tests
26
 
27
+ # Cleanup: Terminate all active solver jobs and shutdown solver manager
28
+ try:
29
+ from constraint_solvers.timetable.solver import solver_manager
30
+ from src.state import app_state
31
+
32
+ # Clear all stored schedules first
33
+ app_state.clear_solved_schedules()
34
+
35
+ # Terminate all active solver jobs gracefully using the Timefold terminateEarly method
36
+ if hasattr(solver_manager, "terminateEarly"):
37
+ # According to Timefold docs, terminateEarly() affects all jobs for this manager
38
+ try:
39
+ solver_manager.terminateEarly()
40
+ print("🧹 Terminated all active solver jobs")
41
+ except Exception as e:
42
+ print(f"⚠️ Error terminating solver jobs: {e}")
43
+
44
+ # Try additional cleanup methods if available
45
+ if hasattr(solver_manager, "close"):
46
+ solver_manager.close()
47
+ print("πŸ”’ Closed solver manager")
48
+ elif hasattr(solver_manager, "shutdown"):
49
+ solver_manager.shutdown()
50
+ print("πŸ”’ Shutdown solver manager")
51
+ else:
52
+ print("⚠️ No explicit close/shutdown method found on solver manager")
53
+
54
+ print("βœ… Solver cleanup completed successfully")
55
+
56
+ except Exception as e:
57
+ print(f"⚠️ Error during solver cleanup: {e}")
58
+ # Don't fail tests if cleanup fails, but log it
59
+
60
+
61
+ # Test Configuration
62
+ TEST_CONFIG = {
63
+ "valid_calendar": "tests/data/calendar.ics",
64
+ "invalid_calendar": "tests/data/calendar_wrong.ics",
65
+ "default_employee_count": 1,
66
+ "default_project_id": "PROJECT",
67
+ "solver_max_polls": 30,
68
+ "solver_poll_interval": 1,
69
+ "datetime_tolerance_seconds": 60,
70
+ }
71
+
72
+
73
+ # Fixtures and Helper Functions
74
+ @pytest.fixture
75
+ def valid_calendar_entries():
76
+ """Load valid calendar entries for testing."""
77
+ return load_calendar_entries(TEST_CONFIG["valid_calendar"])
78
+
79
+
80
+ @pytest.fixture
81
+ def invalid_calendar_entries():
82
+ """Load invalid calendar entries for testing."""
83
+ return load_calendar_entries(TEST_CONFIG["invalid_calendar"])
84
+
85
+
86
+ def load_calendar_entries(file_path: str) -> List[Dict]:
87
+ """Load and extract calendar entries from an iCS file."""
88
+ with open(file_path, "rb") as f:
89
+ file_bytes = f.read()
90
+
91
+ entries, error = extract_ical_entries(file_bytes)
92
+ assert error is None, f"Calendar extraction failed: {error}"
93
+ assert len(entries) > 0, "No calendar entries found"
94
+
95
+ return entries
96
+
97
+
98
+ def print_calendar_entries(entries: List[Dict], title: str = "Calendar Entries"):
99
+ """Print calendar entries in a formatted way."""
100
+ print(f"\nπŸ“… {title} ({len(entries)} entries):")
101
+ for i, entry in enumerate(entries):
102
+ start_dt = entry.get("start_datetime")
103
+ end_dt = entry.get("end_datetime")
104
+ print(f" {i+1}. {entry['summary']}: {start_dt} β†’ {end_dt}")
105
+
106
+
107
+ def calculate_required_schedule_days(
108
+ calendar_entries: List[Dict], buffer_days: int = 30
109
+ ) -> int:
110
+ """Calculate required schedule days based on calendar entries."""
111
+ if not calendar_entries:
112
+ return 60 # Default
113
+
114
+ earliest_date = None
115
+ latest_date = None
116
+
117
+ for entry in calendar_entries:
118
+ for dt_key in ["start_datetime", "end_datetime"]:
119
+ dt = entry.get(dt_key)
120
+ if dt and isinstance(dt, datetime):
121
+ entry_date = dt.date()
122
+ if earliest_date is None or entry_date < earliest_date:
123
+ earliest_date = entry_date
124
+ if latest_date is None or entry_date > latest_date:
125
+ latest_date = entry_date
126
+
127
+ if earliest_date and latest_date:
128
+ calendar_span = (latest_date - earliest_date).days + 1
129
+ return calendar_span + buffer_days
130
+ else:
131
+ return 60 # Fallback
132
+
133
+
134
+ async def generate_mcp_data_helper(
135
+ calendar_entries: List[Dict],
136
+ user_message: str,
137
+ project_id: str = None,
138
+ employee_count: int = None,
139
+ days_in_schedule: int = None,
140
+ ) -> pd.DataFrame:
141
+ """Helper function to generate MCP data with consistent defaults."""
142
+ project_id = project_id or TEST_CONFIG["default_project_id"]
143
+ employee_count = employee_count or TEST_CONFIG["default_employee_count"]
144
+
145
+ if days_in_schedule is None:
146
+ days_in_schedule = calculate_required_schedule_days(calendar_entries)
147
+
148
+ return await data_provider.generate_mcp_data(
149
+ calendar_entries=calendar_entries,
150
+ user_message=user_message,
151
+ project_id=project_id,
152
+ employee_count=employee_count,
153
+ days_in_schedule=days_in_schedule,
154
+ )
155
+
156
+
157
+ async def solve_schedule_with_polling(
158
+ initial_df: pd.DataFrame, employee_count: int = None
159
+ ) -> Optional[pd.DataFrame]:
160
+ """Solve schedule with polling and return the result."""
161
+ employee_count = employee_count or TEST_CONFIG["default_employee_count"]
162
+ required_days = calculate_required_schedule_days([]) # Use default
163
+
164
+ # Extract date range from pinned tasks for better schedule length calculation
165
+ pinned_tasks = initial_df[initial_df.get("Pinned", False) == True]
166
+ if not pinned_tasks.empty:
167
+ required_days = calculate_required_schedule_days_from_df(pinned_tasks)
168
+
169
+ state_data = {
170
+ "task_df_json": initial_df.to_json(orient="split"),
171
+ "employee_count": employee_count,
172
+ "days_in_schedule": required_days,
173
+ }
174
+
175
+ # Start solving
176
+ (
177
+ emp_df,
178
+ task_df,
179
+ job_id,
180
+ status,
181
+ state_data,
182
+ ) = await ScheduleService.solve_schedule_from_state(
183
+ state_data=state_data, job_id=None, debug=True
184
+ )
185
+
186
+ print(f"Solver started with job_id: {job_id}")
187
+ print(f"Initial status: {status}")
188
+
189
+ # Poll for solution using the correct StateService methods
190
+ max_polls = TEST_CONFIG["solver_max_polls"]
191
+ poll_interval = TEST_CONFIG["solver_poll_interval"]
192
+
193
+ final_df = None
194
+
195
+ try:
196
+ for poll_count in range(1, max_polls + 1):
197
+ print(f" Polling {poll_count}/{max_polls}...")
198
+ time.sleep(poll_interval)
199
+
200
+ # Use StateService to check for completed solution
201
+ if StateService.has_solved_schedule(job_id):
202
+ solved_schedule = StateService.get_solved_schedule(job_id)
203
+
204
+ if solved_schedule is not None:
205
+ print(f"βœ… Schedule solved after {poll_count} polls!")
206
+
207
+ # Convert solved schedule to DataFrame
208
+ final_df = schedule_to_dataframe(solved_schedule)
209
+
210
+ # Generate status message to check for failures
211
+ status_message = ScheduleService.generate_status_message(
212
+ solved_schedule
213
+ )
214
+
215
+ if "CONSTRAINTS VIOLATED" in status_message:
216
+ print(f"❌ Solver failed: {status_message}")
217
+ final_df = None
218
+ else:
219
+ print(f"βœ… Solver succeeded: {status_message}")
220
+
221
+ break
222
+
223
+ if final_df is None:
224
+ print("⏰ Solver timed out after max polls")
225
+
226
+ finally:
227
+ # Clean up: Ensure solver job is terminated
228
+ try:
229
+ from constraint_solvers.timetable.solver import solver_manager
230
+
231
+ # Terminate the specific job to free resources using Timefold's terminateEarly
232
+ if hasattr(solver_manager, "terminateEarly"):
233
+ try:
234
+ solver_manager.terminateEarly(job_id)
235
+ print(f"🧹 Terminated solver job: {job_id}")
236
+ except Exception as e:
237
+ # If specific job termination fails, try to terminate all jobs
238
+ print(f"⚠️ Error terminating specific job {job_id}: {e}")
239
+ try:
240
+ solver_manager.terminateEarly()
241
+ print(
242
+ f"🧹 Terminated all solver jobs after specific termination failed"
243
+ )
244
+ except Exception as e2:
245
+ print(f"⚠️ Could not terminate any solver jobs: {e2}")
246
+ else:
247
+ print(f"⚠️ terminateEarly method not available on solver_manager")
248
+ except Exception as e:
249
+ print(f"⚠️ Could not access solver_manager for cleanup: {e}")
250
+
251
+ return final_df
252
+
253
+
254
+ def calculate_required_schedule_days_from_df(
255
+ pinned_df: pd.DataFrame, buffer_days: int = 30
256
+ ) -> int:
257
+ """Calculate required schedule days from DataFrame with pinned tasks."""
258
+ earliest_date = None
259
+ latest_date = None
260
+
261
+ for _, row in pinned_df.iterrows():
262
+ for date_col in ["Start", "End"]:
263
+ date_val = row.get(date_col)
264
+ if date_val is not None:
265
+ try:
266
+ if isinstance(date_val, str):
267
+ dt = datetime.fromisoformat(date_val.replace("Z", "+00:00"))
268
+ else:
269
+ dt = pd.to_datetime(date_val).to_pydatetime()
270
+
271
+ if earliest_date is None or dt.date() < earliest_date:
272
+ earliest_date = dt.date()
273
+ if latest_date is None or dt.date() > latest_date:
274
+ latest_date = dt.date()
275
+ except:
276
+ continue
277
+
278
+ if earliest_date and latest_date:
279
+ calendar_span = (latest_date - earliest_date).days + 1
280
+ return calendar_span + buffer_days
281
+ else:
282
+ return 60 # Default
283
+
284
+
285
+ def analyze_schedule_dataframe(
286
+ df: pd.DataFrame, title: str = "Schedule Analysis"
287
+ ) -> Dict[str, Any]:
288
+ """Analyze a schedule DataFrame and return summary information."""
289
+ existing_tasks = df[df["Project"] == "EXISTING"]
290
+ project_tasks = df[df["Project"] == "PROJECT"]
291
+
292
+ analysis = {
293
+ "total_tasks": len(df),
294
+ "existing_tasks": len(existing_tasks),
295
+ "project_tasks": len(project_tasks),
296
+ "existing_df": existing_tasks,
297
+ "project_df": project_tasks,
298
+ }
299
+
300
+ print(f"\nπŸ“Š {title} ({analysis['total_tasks']} tasks):")
301
+ print(f" - EXISTING (calendar): {analysis['existing_tasks']} tasks")
302
+ print(f" - PROJECT (LLM): {analysis['project_tasks']} tasks")
303
+
304
+ return analysis
305
+
306
+
307
+ def verify_calendar_tasks_pinned(existing_tasks_df: pd.DataFrame) -> bool:
308
+ """Verify that all calendar tasks are pinned."""
309
+ print(f"\nπŸ”’ Verifying calendar tasks are pinned:")
310
+ all_pinned = True
311
+
312
+ for _, task in existing_tasks_df.iterrows():
313
+ is_pinned = task.get("Pinned", False)
314
+ task_name = task["Task"]
315
+ print(f" - {task_name}: pinned = {is_pinned}")
316
+
317
+ if not is_pinned:
318
+ all_pinned = False
319
+ print(f" ❌ Calendar task should be pinned!")
320
+ else:
321
+ print(f" βœ… Calendar task properly pinned")
322
+
323
+ return all_pinned
324
+
325
+
326
+ def verify_time_preservation(
327
+ original_times: Dict, final_tasks_df: pd.DataFrame
328
+ ) -> bool:
329
+ """Verify that calendar tasks preserved their original times."""
330
+ print(f"\nπŸ” Verifying calendar tasks preserved their original times:")
331
+ time_preserved = True
332
+
333
+ for _, task in final_tasks_df.iterrows():
334
+ task_name = task["Task"]
335
+ final_start = task["Start"]
336
+
337
+ original = original_times.get(task_name)
338
+ if original is None:
339
+ print(f" - {task_name}: ❌ Not found in original data")
340
+ time_preserved = False
341
+ continue
342
+
343
+ # Normalize and compare times
344
+ preserved = compare_datetime_values(original["start"], final_start)
345
+
346
+ print(f" - {task_name}:")
347
+ print(f" Original: {original['start']}")
348
+ print(f" Final: {final_start}")
349
+ print(f" Preserved: {'βœ…' if preserved else '❌'}")
350
+
351
+ if not preserved:
352
+ time_preserved = False
353
+
354
+ return time_preserved
355
+
356
+
357
+ def compare_datetime_values(dt1: Any, dt2: Any, tolerance_seconds: int = None) -> bool:
358
+ """Compare two datetime values with tolerance for timezone differences."""
359
+ tolerance = tolerance_seconds or TEST_CONFIG["datetime_tolerance_seconds"]
360
+
361
+ # Convert to comparable datetime objects
362
+ try:
363
+ if isinstance(dt1, str):
364
+ dt1 = datetime.fromisoformat(dt1.replace("Z", "+00:00"))
365
+
366
+ if isinstance(dt2, str):
367
+ dt2 = datetime.fromisoformat(dt2.replace("Z", "+00:00"))
368
+
369
+ # Normalize timezones for comparison
370
+ if dt1.tzinfo is not None and dt2.tzinfo is None:
371
+ dt1 = dt1.replace(tzinfo=None)
372
+ elif dt1.tzinfo is None and dt2.tzinfo is not None:
373
+ dt2 = dt2.replace(tzinfo=None)
374
+
375
+ return abs((dt1 - dt2).total_seconds()) < tolerance
376
+ except:
377
+ return False
378
+
379
+
380
+ def store_original_calendar_times(existing_tasks_df: pd.DataFrame) -> Dict[str, Dict]:
381
+ """Store original calendar task times for later comparison."""
382
+ original_times = {}
383
+
384
+ for _, task in existing_tasks_df.iterrows():
385
+ original_times[task["Task"]] = {
386
+ "start": task["Start"],
387
+ "end": task["End"],
388
+ "pinned": task.get("Pinned", False),
389
+ }
390
+
391
+ print("\nπŸ“Œ Original calendar task times:")
392
+ for task_name, times in original_times.items():
393
+ print(
394
+ f" - {task_name}: {times['start']} β†’ {times['end']} (pinned: {times['pinned']})"
395
+ )
396
+
397
+ return original_times
398
+
399
+
400
+ def verify_llm_tasks_scheduled(project_tasks_df: pd.DataFrame) -> bool:
401
+ """Verify that LLM tasks are properly scheduled and not pinned."""
402
+ print(f"\nπŸ”„ Verifying LLM tasks were properly scheduled:")
403
+ all_scheduled = True
404
+
405
+ for _, task in project_tasks_df.iterrows():
406
+ task_name = task["Task"]
407
+ start_time = task["Start"]
408
+ is_pinned = task.get("Pinned", False)
409
+
410
+ print(f" - {task_name}:")
411
+ print(f" Scheduled at: {start_time}")
412
+ print(f" Pinned: {is_pinned}")
413
+
414
+ # LLM tasks should not be pinned
415
+ if is_pinned:
416
+ all_scheduled = False
417
+ print(f" ❌ LLM task should not be pinned!")
418
+ else:
419
+ print(f" βœ… LLM task properly unpinned")
420
+
421
+ # LLM tasks should have been scheduled to actual times
422
+ if start_time is None or start_time == "":
423
+ all_scheduled = False
424
+ print(f" ❌ LLM task was not scheduled!")
425
+ else:
426
+ print(f" βœ… LLM task was scheduled")
427
+
428
+ return all_scheduled
429
+
430
+
431
+ # Test Functions
432
  @pytest.mark.asyncio
433
  async def test_factory_demo_agent():
434
  # Use a simple string as the project description
 
464
 
465
 
466
  @pytest.mark.asyncio
467
+ async def test_factory_mcp(valid_calendar_entries):
468
+ print_calendar_entries(valid_calendar_entries, "Loaded Calendar Entries")
 
 
 
 
 
 
 
 
 
469
 
470
  # Use a made-up user message
471
  user_message = "Create a new AWS VPC."
472
 
473
  # Call generate_mcp_data directly
474
+ df = await generate_mcp_data_helper(valid_calendar_entries, user_message)
475
 
476
  # Assert the DataFrame is not empty
477
  assert df is not None
 
479
 
480
  # Print the DataFrame for debug
481
  print(df)
482
+
483
+
484
+ @pytest.mark.asyncio
485
+ async def test_mcp_workflow_calendar_pinning(valid_calendar_entries):
486
+ """
487
+ Test that verifies calendar tasks (EXISTING) remain pinned to their original times
488
+ while LLM tasks (PROJECT) are rescheduled around them in the MCP workflow.
489
+ """
490
+ print("\n" + "=" * 60)
491
+ print("Testing MCP Workflow: Calendar Task Pinning vs LLM Task Scheduling")
492
+ print("=" * 60)
493
+
494
+ print_calendar_entries(valid_calendar_entries, "Loaded Calendar Entries")
495
+
496
+ # Generate initial MCP data
497
+ user_message = "Set up CI/CD pipeline and configure monitoring system"
498
+ initial_df = await generate_mcp_data_helper(valid_calendar_entries, user_message)
499
+
500
+ # Analyze initial schedule
501
+ analysis = analyze_schedule_dataframe(initial_df, "Generated Initial Data")
502
+
503
+ # Store original calendar task times and verify they're pinned
504
+ original_times = store_original_calendar_times(analysis["existing_df"])
505
+ calendar_pinned = verify_calendar_tasks_pinned(analysis["existing_df"])
506
+ assert calendar_pinned, "Calendar tasks should be pinned!"
507
+
508
+ # Solve the schedule
509
+ print(f"\nπŸ”§ Running MCP workflow to solve schedule...")
510
+ solved_schedule_df = await solve_schedule_with_polling(initial_df)
511
+
512
+ if solved_schedule_df is None:
513
+ print("⏰ Solver timed out - this might be due to complex constraints")
514
+ print("⚠️ Skipping verification steps for timeout case")
515
+ return
516
+
517
+ # Analyze final schedule (solved_schedule_df is already a DataFrame)
518
+ final_analysis = analyze_schedule_dataframe(solved_schedule_df, "Final Schedule")
519
+
520
+ # Verify calendar tasks preserved their times
521
+ time_preserved = verify_time_preservation(
522
+ original_times, final_analysis["existing_df"]
523
+ )
524
+
525
+ # Verify LLM tasks were properly scheduled
526
+ llm_scheduled = verify_llm_tasks_scheduled(final_analysis["project_df"])
527
+
528
+ # Final assertions
529
+ assert time_preserved, "Calendar tasks did not preserve their original times!"
530
+ assert llm_scheduled, "LLM tasks were not properly scheduled!"
531
+
532
+ print(f"\nπŸŽ‰ MCP Workflow Test Results:")
533
+ print(f"βœ… Calendar tasks preserved original times: {time_preserved}")
534
+ print(f"βœ… LLM tasks were properly scheduled: {llm_scheduled}")
535
+ print(
536
+ "🎯 MCP workflow test passed! Calendar tasks are pinned, LLM tasks are flexible."
537
+ )
538
+
539
+
540
+ @pytest.mark.asyncio
541
+ async def test_calendar_validation_rejects_invalid_entries(invalid_calendar_entries):
542
+ """
543
+ Test that calendar validation properly rejects entries that violate working hours constraints.
544
+ """
545
+ print("\n" + "=" * 60)
546
+ print("Testing Calendar Validation: Constraint Violations")
547
+ print("=" * 60)
548
+
549
+ print_calendar_entries(invalid_calendar_entries, "Invalid Calendar Entries")
550
+
551
+ # Test that generate_mcp_data raises an error due to validation failure
552
+ user_message = "Simple test task"
553
+
554
+ print(f"\n❌ Attempting to generate MCP data with invalid calendar (should fail)...")
555
+
556
+ with pytest.raises(ValueError) as exc_info:
557
+ await generate_mcp_data_helper(invalid_calendar_entries, user_message)
558
+
559
+ error_message = str(exc_info.value)
560
+ print(f"\nβœ… Validation correctly rejected invalid calendar:")
561
+ print(f"Error: {error_message}")
562
+
563
+ # Verify the error message contains expected constraint violations
564
+ assert "Calendar entries violate working constraints" in error_message
565
+ # Check for specific violations that should be detected
566
+ assert (
567
+ "Early Morning Meeting" in error_message
568
+ or "07:00" in error_message
569
+ or "before 9:00" in error_message
570
+ ), f"Should detect early morning violation in: {error_message}"
571
+ assert (
572
+ "Evening Meeting" in error_message
573
+ or "21:00" in error_message
574
+ or "after 18:00" in error_message
575
+ ), f"Should detect evening violation in: {error_message}"
576
+ assert (
577
+ "Very Late Meeting" in error_message or "22:00" in error_message
578
+ ), f"Should detect very late violation in: {error_message}"
579
+
580
+ print("βœ… All expected constraint violations were detected!")
581
+
582
+
583
+ @pytest.mark.asyncio
584
+ async def test_calendar_validation_accepts_valid_entries(valid_calendar_entries):
585
+ """
586
+ Test that calendar validation accepts valid entries and processing continues normally.
587
+ """
588
+ print("\n" + "=" * 60)
589
+ print("Testing Calendar Validation: Valid Entries")
590
+ print("=" * 60)
591
+
592
+ print_calendar_entries(valid_calendar_entries, "Valid Calendar Entries")
593
+
594
+ # Test that generate_mcp_data succeeds with valid calendar
595
+ user_message = "Simple test task"
596
+
597
+ print(
598
+ f"\nβœ… Attempting to generate MCP data with valid calendar (should succeed)..."
599
+ )
600
+
601
+ try:
602
+ initial_df = await generate_mcp_data_helper(
603
+ valid_calendar_entries, user_message
604
+ )
605
+
606
+ print(f"βœ… Validation passed! Generated {len(initial_df)} tasks successfully")
607
+
608
+ # Analyze and verify the result
609
+ analysis = analyze_schedule_dataframe(initial_df, "Generated Schedule")
610
+
611
+ assert analysis["existing_tasks"] > 0, "Should have calendar tasks"
612
+ assert analysis["project_tasks"] > 0, "Should have LLM tasks"
613
+
614
+ # Verify all calendar tasks are pinned
615
+ calendar_pinned = verify_calendar_tasks_pinned(analysis["existing_df"])
616
+ assert calendar_pinned, "All calendar tasks should be properly pinned!"
617
+
618
+ except Exception as e:
619
+ pytest.fail(f"Valid calendar should not raise an error, but got: {e}")
620
+
621
+
622
+ @pytest.mark.asyncio
623
+ async def test_mcp_backend_end_to_end():
624
+ """
625
+ Test the complete MCP backend workflow using the actual handler function.
626
+ This tests the full process_message_and_attached_file flow.
627
+ """
628
+ print("\n" + "=" * 50)
629
+ print("Testing MCP Backend End-to-End")
630
+ print("=" * 50)
631
+
632
+ # Test message for LLM tasks
633
+ message_body = "Implement user authentication and setup database migrations"
634
+ file_path = TEST_CONFIG["valid_calendar"]
635
+
636
+ # Run the MCP backend handler
637
+ print(f"πŸ“¨ Processing message: '{message_body}'")
638
+ print(f"πŸ“ Using calendar file: {file_path}")
639
+
640
+ result = await process_message_and_attached_file(file_path, message_body)
641
+
642
+ # Verify the result structure
643
+ assert isinstance(result, dict), "Result should be a dictionary"
644
+ assert result.get("status") in [
645
+ "success",
646
+ "timeout",
647
+ ], f"Unexpected status: {result.get('status')}"
648
+
649
+ if result.get("status") == "success":
650
+ print("βœ… MCP backend completed successfully!")
651
+
652
+ # Verify result contains expected fields
653
+ assert "schedule" in result, "Result should contain schedule data"
654
+ assert "calendar_entries" in result, "Result should contain calendar entries"
655
+ assert "file_info" in result, "Result should contain file info"
656
+
657
+ schedule = result["schedule"]
658
+ calendar_entries = result["calendar_entries"]
659
+
660
+ print(f"πŸ“… Calendar entries processed: {len(calendar_entries)}")
661
+ print(f"πŸ“‹ Total scheduled tasks: {len(schedule)}")
662
+
663
+ # Analyze the schedule
664
+ existing_tasks = [t for t in schedule if t.get("Project") == "EXISTING"]
665
+ project_tasks = [t for t in schedule if t.get("Project") == "PROJECT"]
666
+
667
+ print(f"πŸ”’ EXISTING (calendar) tasks: {len(existing_tasks)}")
668
+ print(f"πŸ”§ PROJECT (LLM) tasks: {len(project_tasks)}")
669
+
670
+ # Verify we have both types of tasks
671
+ assert len(existing_tasks) > 0, "Should have calendar tasks"
672
+ assert len(project_tasks) > 0, "Should have LLM-generated tasks"
673
+
674
+ # Check that project tasks exist and are scheduled
675
+ for task in project_tasks:
676
+ task_name = task.get("Task", "Unknown")
677
+ start_time = task.get("Start")
678
+ print(f"⏰ LLM task '{task_name}': scheduled at {start_time}")
679
+ assert (
680
+ start_time is not None
681
+ ), f"LLM task '{task_name}' should have a scheduled start time"
682
+
683
+ print("🎯 MCP backend end-to-end test passed!")
684
+
685
+ elif result.get("status") == "timeout":
686
+ print("⏰ MCP backend timed out - this is acceptable for testing")
687
+ print("The solver may need more time for complex schedules")
688
+
689
+ # Still verify basic structure
690
+ assert "calendar_entries" in result, "Result should contain calendar entries"
691
+ assert "file_info" in result, "Result should contain file info"
692
+
693
+ else:
694
+ # Handle error cases
695
+ error_msg = result.get("error", "Unknown error")
696
+ print(f"❌ MCP backend failed: {error_msg}")
697
+ assert False, f"MCP backend failed: {error_msg}"
698
+
699
+ print("βœ… MCP backend structure and behavior verified!")
700
+
701
+
702
+ @pytest.mark.asyncio
703
+ async def test_mcp_datetime_debug(valid_calendar_entries):
704
+ """
705
+ Debug test to isolate the datetime conversion issue in MCP workflow.
706
+ """
707
+ print("\n" + "=" * 50)
708
+ print("Testing MCP Datetime Conversion Debug")
709
+ print("=" * 50)
710
+
711
+ print(f"\nπŸ“… Calendar entries debug:")
712
+ for i, entry in enumerate(valid_calendar_entries):
713
+ print(f" {i+1}. {entry['summary']}:")
714
+ print(
715
+ f" start_datetime: {entry.get('start_datetime')} (type: {type(entry.get('start_datetime'))})"
716
+ )
717
+ print(
718
+ f" end_datetime: {entry.get('end_datetime')} (type: {type(entry.get('end_datetime'))})"
719
+ )
720
+
721
+ # Generate MCP data and check the DataFrame structure
722
+ user_message = "Simple test task"
723
+
724
+ try:
725
+ # Generate data with calculated schedule length
726
+ required_days = calculate_required_schedule_days(
727
+ valid_calendar_entries, buffer_days=10
728
+ )
729
+ print(f"πŸ“Š Using {required_days} total schedule days")
730
+
731
+ initial_df = await generate_mcp_data_helper(
732
+ valid_calendar_entries, user_message, days_in_schedule=required_days
733
+ )
734
+
735
+ print(f"\nπŸ“Š Generated DataFrame columns: {list(initial_df.columns)}")
736
+ print(f"πŸ“Š DataFrame shape: {initial_df.shape}")
737
+ print(f"πŸ“Š DataFrame dtypes:\n{initial_df.dtypes}")
738
+
739
+ # Check the Start and End column formats
740
+ print(f"\nπŸ•’ Start column sample:")
741
+ for i, row in initial_df.head(3).iterrows():
742
+ start_val = row.get("Start")
743
+ print(f" Row {i}: {start_val} (type: {type(start_val)})")
744
+
745
+ # Test conversion to JSON and back
746
+ json_str = initial_df.to_json(orient="split")
747
+ print(f"\nπŸ“„ JSON conversion successful")
748
+
749
+ # Test parsing back
750
+ task_df_back = pd.read_json(StringIO(json_str), orient="split")
751
+ print(f"πŸ“„ JSON parsing back successful")
752
+ print(f"πŸ“„ Parsed dtypes:\n{task_df_back.dtypes}")
753
+
754
+ # Test task conversion with minimal error handling
755
+ print(f"\nπŸ”„ Testing task conversion...")
756
+
757
+ # Only try with the first task to isolate issues
758
+ single_task_df = task_df_back.head(1)
759
+ print(f"Single task for testing:\n{single_task_df}")
760
+
761
+ tasks = DataService.convert_dataframe_to_tasks(single_task_df)
762
+ print(f"βœ… Successfully converted {len(tasks)} tasks")
763
+
764
+ for task in tasks:
765
+ print(f" Task: {task.description}")
766
+ print(f" start_slot: {task.start_slot} (type: {type(task.start_slot)})")
767
+ print(f" pinned: {task.pinned}")
768
+ print(f" project_id: {task.project_id}")
769
+
770
+ except Exception as e:
771
+ print(f"❌ Error in MCP data generation/conversion: {e}")
772
+ traceback.print_exc()
773
+ raise
774
+
775
+ print("🎯 MCP datetime debug test completed!")