File size: 12,506 Bytes
3b9a6b5
2004c79
3b9a6b5
 
 
2004c79
3b9a6b5
e3a1efe
3b9a6b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
918bdb4
 
 
3b9a6b5
 
 
918bdb4
3b9a6b5
 
 
 
 
 
 
 
 
 
918bdb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b9a6b5
918bdb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3b9a6b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2004c79
3b9a6b5
 
 
 
 
 
 
e3a1efe
3b9a6b5
 
2004c79
e3a1efe
3b9a6b5
 
 
 
 
2004c79
 
 
3b9a6b5
 
 
 
918bdb4
e3a1efe
 
 
 
 
 
 
 
 
 
 
 
 
2004c79
 
 
 
e3a1efe
 
2004c79
e3a1efe
3b9a6b5
2004c79
918bdb4
3b9a6b5
 
 
 
2004c79
3b9a6b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2004c79
3b9a6b5
2004c79
 
3b9a6b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2004c79
 
3b9a6b5
2004c79
 
3b9a6b5
2004c79
3b9a6b5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2004c79
3b9a6b5
2004c79
3b9a6b5
 
 
 
 
 
2004c79
3b9a6b5
 
 
 
 
2004c79
3b9a6b5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
from datetime import date, timedelta
import random
from random import Random
from itertools import product

from factory.data.models import *
from constraint_solvers.timetable.domain import *
from utils.extract_calendar import datetime_to_slot, calculate_duration_slots


### EMPLOYEES ###
FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
LAST_NAMES = (
    "Cole",
    "Fox",
    "Green",
    "Jones",
    "King",
    "Li",
    "Poe",
    "Rye",
    "Smith",
    "Watt",
)


def generate_employees(
    parameters: TimeTableDataParameters,
    random: Random,
    required_skills_needed: set[str] = None,
) -> list[Employee]:
    """
    Generates a list of Employee objects with random names and skills.
    Ensures that collectively the employees have all required_skills_needed.
    """
    name_permutations = [
        f"{first_name} {last_name}"
        for first_name, last_name in product(FIRST_NAMES, LAST_NAMES)
    ]

    random.shuffle(name_permutations)

    employees = []

    # If specific skills are needed, ensure they're covered
    if required_skills_needed:
        skills_needed = set(required_skills_needed)

        # For single employee (MCP case), give them all needed skills plus some random ones
        if parameters.employee_count == 1:
            all_available_skills = list(parameters.skill_set.required_skills) + list(
                parameters.skill_set.optional_skills
            )
            # Give all available skills to the single employee to handle any task
            employees.append(
                Employee(name=name_permutations[0], skills=set(all_available_skills))
            )
            return employees

        # For multiple employees, distribute needed skills and add random skills
        for i in range(parameters.employee_count):
            (count,) = random.choices(
                population=counts(parameters.optional_skill_distribution),
                weights=weights(parameters.optional_skill_distribution),
            )
            count = min(count, len(parameters.skill_set.optional_skills))

            skills = []

            # Ensure each employee gets at least one required skill
            skills += random.sample(parameters.skill_set.required_skills, 1)

            # Add random optional skills
            skills += random.sample(parameters.skill_set.optional_skills, count)

            # If there are still skills needed and this is one of the first employees,
            # ensure they get some of the needed skills
            if skills_needed and i < len(skills_needed):
                needed_skill = skills_needed.pop()
                if needed_skill not in skills:
                    skills.append(needed_skill)

            employees.append(Employee(name=name_permutations[i], skills=set(skills)))

    else:
        # Original random generation when no specific skills are needed
        for i in range(parameters.employee_count):
            (count,) = random.choices(
                population=counts(parameters.optional_skill_distribution),
                weights=weights(parameters.optional_skill_distribution),
            )
            count = min(count, len(parameters.skill_set.optional_skills))

            skills = []
            skills += random.sample(parameters.skill_set.optional_skills, count)
            skills += random.sample(parameters.skill_set.required_skills, 1)
            employees.append(Employee(name=name_permutations[i], skills=set(skills)))

    return employees


def generate_employee_availability(
    employees: list[Employee],
    parameters: TimeTableDataParameters,
    start_date: date,
    random: Random,
) -> None:
    """
    Sets up random availability preferences for employees proportional to schedule length.

    For 365 days:
    - Max 21 unavailable days per employee
    - Max 0-12 undesired days per employee
    - Desired dates remain flexible (0-12 days)

    Scales proportionally for different schedule lengths.
    """
    days_in_schedule = parameters.days_in_schedule

    # Calculate proportional limits based on 365-day baseline
    max_unavailable_per_employee = round((21 / 365) * days_in_schedule)
    max_undesired_per_employee = round((12 / 365) * days_in_schedule)
    max_desired_per_employee = round((12 / 365) * days_in_schedule)

    # Ensure minimum reasonable values
    max_unavailable_per_employee = max(1, max_unavailable_per_employee)
    max_undesired_per_employee = max(0, max_undesired_per_employee)
    max_desired_per_employee = max(0, max_desired_per_employee)

    # Generate all possible dates in the schedule
    all_dates = [start_date + timedelta(days=i) for i in range(days_in_schedule)]

    for employee in employees:
        # Randomly assign unavailable dates (1 to max_unavailable_per_employee)
        num_unavailable = random.randint(1, max_unavailable_per_employee)
        unavailable_dates = random.sample(
            all_dates, min(num_unavailable, len(all_dates))
        )
        employee.unavailable_dates.update(unavailable_dates)

        # Remove unavailable dates from remaining pool for other preferences
        remaining_dates = [d for d in all_dates if d not in employee.unavailable_dates]

        # Randomly assign undesired dates (0 to max_undesired_per_employee)
        if max_undesired_per_employee > 0 and remaining_dates:
            num_undesired = random.randint(
                0, min(max_undesired_per_employee, len(remaining_dates))
            )
            if num_undesired > 0:
                undesired_dates = random.sample(remaining_dates, num_undesired)
                employee.undesired_dates.update(undesired_dates)
                remaining_dates = [
                    d for d in remaining_dates if d not in employee.undesired_dates
                ]

        # Randomly assign desired dates (0 to max_desired_per_employee)
        if max_desired_per_employee > 0 and remaining_dates:
            num_desired = random.randint(
                0, min(max_desired_per_employee, len(remaining_dates))
            )
            if num_desired > 0:
                desired_dates = random.sample(remaining_dates, num_desired)
                employee.desired_dates.update(desired_dates)


def generate_employee_availability_mcp(
    employees: list[Employee],
) -> None:
    """
    For MCP data generator: does not set any unavailable, desired, or undesired days for employees.
    All availability sets remain empty.
    """
    for employee in employees:
        employee.unavailable_dates.clear()
        employee.undesired_dates.clear()
        employee.desired_dates.clear()


def generate_tasks(
    parameters: TimeTableDataParameters,
    random: Random,
    task_tuples: list[tuple[str, int]],
) -> list[Task]:
    """
    Given a list of (description, duration) tuples, generate Task objects with randomized required_skill.
    """
    tasks: list[Task] = []

    ids = generate_task_ids()

    for description, duration in task_tuples:
        if random.random() >= 0.5:
            required_skill = random.choice(parameters.skill_set.required_skills)
        else:
            required_skill = random.choice(parameters.skill_set.optional_skills)
        tasks.append(
            Task(
                id=next(ids),
                description=description,
                duration_slots=duration,
                start_slot=0,  # This will be assigned by the solver
                required_skill=required_skill,
            )
        )

    return tasks


def generate_tasks_from_calendar(
    parameters: TimeTableDataParameters,
    random: Random,
    calendar_entries: list[dict],
    base_date: date = None,
) -> list[Task]:
    """
    Generate Task objects from calendar entries with Skills.
    Calendar tasks are pinned to their original datetime slots.
    """
    tasks: list[Task] = []
    ids = generate_task_ids()

    for entry in calendar_entries:
        # Get skill from entry or randomly assign
        required_skill = entry.get("skill")
        if not required_skill:
            if random.random() >= 0.5:
                required_skill = random.choice(parameters.skill_set.required_skills)
            else:
                required_skill = random.choice(parameters.skill_set.optional_skills)

        # Calculate start_slot and duration_slots from calendar datetime info
        start_datetime = entry.get("start_datetime")
        end_datetime = entry.get("end_datetime")

        if start_datetime and end_datetime and base_date:
            # Calculate actual slot and duration from calendar times
            start_slot = datetime_to_slot(start_datetime, base_date)
            duration_slots = calculate_duration_slots(start_datetime, end_datetime)
        else:
            # Fallback to default values if datetime info is missing
            start_slot = entry.get("start_slot", 0)
            duration_slots = entry.get("duration_slots", 2)  # Default 1 hour

        tasks.append(
            Task(
                id=next(ids),
                description=entry["summary"],
                duration_slots=duration_slots,
                start_slot=start_slot,
                required_skill=required_skill,
                pinned=True,  # Pin calendar tasks to their original times
            )
        )

    return tasks


def generate_task_ids():
    """Generate sequential task IDs starting from 0."""
    current_id = 0
    while True:
        yield str(current_id)
        current_id += 1


# =========================
#     UTILITY FUNCTIONS
# =========================
def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]:
    """
    Extracts the count values from a tuple of CountDistribution objects.
    """
    return tuple(distribution.count for distribution in distributions)


def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]:
    """
    Extracts the weight values from a tuple of CountDistribution objects.
    """
    return tuple(distribution.weight for distribution in distributions)


def earliest_monday_on_or_after(target_date: date) -> date:
    """
    Returns the earliest Monday on or after the given date.
    """
    days_until_monday = (7 - target_date.weekday()) % 7
    return target_date + timedelta(days=days_until_monday)


def tasks_from_agent_output(agent_output, parameters, project_id: str = ""):
    """
    Convert task_composer_agent output (list of (description, duration, skill)) to Task objects.
    """

    ids = generate_task_ids()
    tasks = []

    for sequence_num, task_data in enumerate(agent_output):
        # Handle both old format (description, duration) and new format (description, duration, skill)
        if len(task_data) == 3:
            description, duration, required_skill = task_data
        elif len(task_data) == 2:
            description, duration = task_data
            # Fallback to random assignment if no skill provided
            # Use a new Random instance for compatibility
            rng = random.Random()

            if rng.random() >= 0.5:
                required_skill = rng.choice(parameters.skill_set.required_skills)
            else:
                required_skill = rng.choice(parameters.skill_set.optional_skills)
        else:
            continue  # skip invalid task data

        try:
            duration_int = int(duration)
        except (ValueError, TypeError):
            continue  # skip this task if duration is invalid

        # Clean up skill name (remove any extra formatting)
        if required_skill:
            required_skill = required_skill.strip()
            # Ensure the skill exists in our skill set
            all_skills = list(parameters.skill_set.required_skills) + list(
                parameters.skill_set.optional_skills
            )
            if required_skill not in all_skills:
                # If skill doesn't match exactly, try to find closest match or fallback to random
                rng = random.Random()

                required_skill = rng.choice(parameters.skill_set.required_skills)

        tasks.append(
            Task(
                id=next(ids),
                description=description,
                duration_slots=duration_int,
                start_slot=0,  # Will be assigned by solver
                required_skill=required_skill,
                project_id=project_id,
                sequence_number=sequence_num,
            )
        )

    return tasks