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!")
|