Spaces:
Paused
Paused
Commit
Β·
e3a1efe
1
Parent(s):
50e3252
feat!: add task pinning system and refactor existing systems
Browse files- README.md +10 -13
- src/constraint_solvers/timetable/constraints.py +15 -37
- src/constraint_solvers/timetable/domain.py +36 -5
- src/constraint_solvers/timetable/working_hours.py +80 -8
- src/factory/data/formatters.py +13 -39
- src/factory/data/generators.py +19 -2
- src/factory/data/provider.py +78 -7
- src/services/data.py +123 -4
- src/services/schedule.py +85 -7
- src/utils/extract_calendar.py +176 -7
- tests/data/calendar.ics +9 -9
- tests/data/calendar_wrong.ics +54 -0
- tests/test_constraints.py +26 -42
- tests/test_factory.py +717 -12
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
|
86 |
-
| **
|
|
|
|
|
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 |
-
- **
|
127 |
-
- **
|
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 |
-
-
|
142 |
-
|
143 |
-
|
144 |
-
|
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 |
-
|
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
|
|
|
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
|
|
|
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
|
|
|
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 {
|
|
|
|
|
|
|
|
|
105 |
|
106 |
@staticmethod
|
107 |
def from_dict(d):
|
108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-
|
6 |
-
|
|
|
7 |
MORNING_SLOTS = 8 # 9:00-13:00 (4 hours * 2 slots/hour)
|
8 |
-
AFTERNOON_SLOTS =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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-
|
25 |
|
26 |
Args:
|
27 |
slot (int): The slot index.
|
28 |
|
29 |
Returns:
|
30 |
-
int: The slot position within the day (0-
|
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 |
-
#
|
48 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
65 |
-
start_time: datetime = slot_to_datetime(task.start_slot)
|
66 |
-
end_time: datetime = slot_to_datetime(
|
|
|
|
|
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=
|
233 |
-
start_slot=
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
parameters = TimeTableDataParameters(
|
146 |
skill_set=parameters.skill_set,
|
147 |
-
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(
|
|
|
|
|
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(
|
|
|
|
|
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=
|
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(
|
|
|
|
|
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 |
-
) =
|
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,
|
|
|
|
|
|
|
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 |
-
|
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 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
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:
|
19 |
-
DTEND;TZID=UTC:
|
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:
|
28 |
-
DTEND;TZID=UTC:
|
29 |
SUMMARY:Client Call
|
30 |
END:VEVENT
|
31 |
|
32 |
BEGIN:VEVENT
|
33 |
UID:single-event-2@mock
|
34 |
DTSTAMP:20240523T000000Z
|
35 |
-
DTSTART;TZID=UTC:
|
36 |
-
DTEND;TZID=UTC:
|
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:
|
45 |
SUMMARY:Planning Session
|
46 |
END:VEVENT
|
47 |
|
48 |
BEGIN:VEVENT
|
49 |
UID:single-event-4@mock
|
50 |
DTSTAMP:20240523T000000Z
|
51 |
-
DTSTART;TZID=UTC:
|
52 |
-
DTEND;TZID=UTC:
|
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
|
219 |
-
start_slot=
|
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 |
-
#
|
249 |
task = create_task(
|
250 |
task_id="task1",
|
251 |
description="Task on unavailable day",
|
252 |
-
start_slot=
|
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
|
265 |
task = create_task(
|
266 |
task_id="task1",
|
267 |
description="Task on available day",
|
268 |
-
start_slot=0, # Today (
|
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=
|
375 |
-
duration_slots=4, #
|
376 |
required_skill="Python",
|
377 |
employee=self.employee_alice,
|
378 |
)
|
@@ -383,30 +383,13 @@ class TestConstraints:
|
|
383 |
.penalizes_by(1)
|
384 |
)
|
385 |
|
386 |
-
def
|
387 |
-
"""Test that tasks
|
388 |
task = create_task(
|
389 |
task_id="task1",
|
390 |
-
description="
|
391 |
-
start_slot=
|
392 |
-
duration_slots=4, #
|
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 |
-
#
|
445 |
task = create_task(
|
446 |
task_id="task1",
|
447 |
description="Task on undesired day",
|
448 |
-
start_slot=
|
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
|
461 |
task = create_task(
|
462 |
task_id="task1",
|
463 |
description="Task on neutral day",
|
464 |
-
start_slot=0, # Today (neutral for Alice
|
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
|
|
|
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=
|
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=
|
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=
|
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("-
|
679 |
)
|
680 |
|
681 |
|
@@ -728,11 +712,11 @@ def create_task(
|
|
728 |
)
|
729 |
|
730 |
|
731 |
-
def create_schedule_info(total_slots=
|
732 |
"""Create a schedule info object with specified total slots.
|
733 |
-
Default is
|
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 |
-
|
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
|
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!")
|