h-adminsim 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. h_adminsim/__init__.py +5 -0
  2. h_adminsim/admin_staff.py +280 -0
  3. h_adminsim/assets/configs/data4primary.yaml +47 -0
  4. h_adminsim/assets/configs/data4secondary.yaml +47 -0
  5. h_adminsim/assets/configs/data4tertiary.yaml +47 -0
  6. h_adminsim/assets/country/address.json +141859 -0
  7. h_adminsim/assets/country/country_code.json +244 -0
  8. h_adminsim/assets/departments/department.json +85 -0
  9. h_adminsim/assets/departments/symptom.json +4530 -0
  10. h_adminsim/assets/fhir.schema.json +75253 -0
  11. h_adminsim/assets/names/firstname.txt +1219 -0
  12. h_adminsim/assets/names/lastname.txt +88799 -0
  13. h_adminsim/assets/prompts/cancel_patient_system.txt +38 -0
  14. h_adminsim/assets/prompts/intake_staff_task_user.txt +16 -0
  15. h_adminsim/assets/prompts/intake_supervisor_system.txt +8 -0
  16. h_adminsim/assets/prompts/intake_supervisor_user.txt +31 -0
  17. h_adminsim/assets/prompts/reschedule_patient_system.txt +38 -0
  18. h_adminsim/assets/prompts/schedule_patient_rejected_system.txt +42 -0
  19. h_adminsim/assets/prompts/schedule_patient_system.txt +36 -0
  20. h_adminsim/assets/prompts/schedule_staff_reasoning.txt +57 -0
  21. h_adminsim/assets/prompts/schedule_staff_sc_tool_calling.txt +13 -0
  22. h_adminsim/assets/prompts/schedule_staff_system.txt +10 -0
  23. h_adminsim/assets/prompts/schedule_staff_tool_calling.txt +41 -0
  24. h_adminsim/client/__init__.py +3 -0
  25. h_adminsim/client/google_client.py +209 -0
  26. h_adminsim/client/openai_client.py +199 -0
  27. h_adminsim/client/vllm_client.py +160 -0
  28. h_adminsim/environment/__init__.py +1 -0
  29. h_adminsim/environment/hospital.py +462 -0
  30. h_adminsim/environment/op_scheduling_simulation.py +1126 -0
  31. h_adminsim/pipeline/__init__.py +3 -0
  32. h_adminsim/pipeline/data_generator.py +192 -0
  33. h_adminsim/pipeline/evaluator.py +33 -0
  34. h_adminsim/pipeline/simulation.py +231 -0
  35. h_adminsim/registry/__init__.py +5 -0
  36. h_adminsim/registry/errors.py +89 -0
  37. h_adminsim/registry/models.py +126 -0
  38. h_adminsim/registry/phrases.py +10 -0
  39. h_adminsim/registry/pydantic_models.py +21 -0
  40. h_adminsim/registry/variables.py +9 -0
  41. h_adminsim/supervisor.py +182 -0
  42. h_adminsim/task/agent_task.py +900 -0
  43. h_adminsim/task/fhir_manager.py +222 -0
  44. h_adminsim/task/schedule_assign.py +151 -0
  45. h_adminsim/tools/__init__.py +5 -0
  46. h_adminsim/tools/agent_data_builder.py +124 -0
  47. h_adminsim/tools/data_converter.py +536 -0
  48. h_adminsim/tools/data_synthesizer.py +365 -0
  49. h_adminsim/tools/evaluator.py +258 -0
  50. h_adminsim/tools/sanity_checker.py +216 -0
  51. h_adminsim/tools/scheduling_rule.py +420 -0
  52. h_adminsim/utils/__init__.py +136 -0
  53. h_adminsim/utils/common_utils.py +698 -0
  54. h_adminsim/utils/fhir_utils.py +190 -0
  55. h_adminsim/utils/filesys_utils.py +135 -0
  56. h_adminsim/utils/image_preprocess_utils.py +188 -0
  57. h_adminsim/utils/random_utils.py +358 -0
  58. h_adminsim/version.txt +1 -0
  59. h_adminsim-1.0.0.dist-info/LICENSE +30 -0
  60. h_adminsim-1.0.0.dist-info/METADATA +494 -0
  61. h_adminsim-1.0.0.dist-info/RECORD +62 -0
  62. h_adminsim-1.0.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,698 @@
1
+ import time
2
+ import pytz
3
+ import random
4
+ from openai import InternalServerError
5
+ from decimal import Decimal, getcontext
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional, Union, Tuple
8
+ from google.genai.errors import ServerError
9
+
10
+ from h_adminsim import registry
11
+ from h_adminsim.registry import Hospital
12
+ from h_adminsim.utils import Information, log, colorstr
13
+
14
+
15
+
16
+ def exponential_backoff(retry_count: int,
17
+ base_delay: int = 5,
18
+ max_delay: int = 65,
19
+ jitter: bool = True) -> float:
20
+ """
21
+ Exponential backoff function for API calling.
22
+
23
+ Args:
24
+ retry_count (int): Retry count.
25
+ base_delay (int, optional): Base delay seconds. Defaults to 5.
26
+ max_delay (int, optional): Maximum delay seconds. Defaults to 165.
27
+ jitter (bool, optional): Whether apply randomness. Defaults to True.
28
+
29
+ Returns:
30
+ float: Final delay time.
31
+ """
32
+ delay = min(base_delay * (2 ** retry_count), max_delay)
33
+ if jitter:
34
+ delay = random.uniform(delay * 0.8, delay * 1.2)
35
+ return delay
36
+
37
+
38
+
39
+ def padded_int(n: int, total_digit_l: int = 3) -> str:
40
+ """
41
+ Convert an integer to a zero-padded string of length 2.
42
+
43
+ Args:
44
+ n (int): The integer to convert.
45
+ total_digit_l (int): The total number of digits in the output string. Default is 3.
46
+
47
+ Returns:
48
+ str: The zero-padded string representation of the integer.
49
+ """
50
+ if n < 0:
51
+ raise ValueError(colorstr("red", "Negative integers are not supported"))
52
+ if total_digit_l <= 0:
53
+ raise ValueError(colorstr("red", "Total digit length must be a positive integer"))
54
+
55
+ return str(n).zfill(total_digit_l)
56
+
57
+
58
+
59
+ def to_dict(obj: Information) -> dict:
60
+ """
61
+ Convert an object to a dictionary representation.
62
+
63
+ Args:
64
+ obj (Information): The object to convert.
65
+
66
+ Returns:
67
+ dict: A dictionary representation of the object.
68
+ """
69
+ if isinstance(obj, Information):
70
+ return {k: to_dict(v) for k, v in obj.__dict__.items()}
71
+ elif isinstance(obj, dict):
72
+ return {k: to_dict(v) for k, v in obj.items()}
73
+ elif isinstance(obj, list):
74
+ return [to_dict(v) for v in obj]
75
+ else:
76
+ return obj
77
+
78
+
79
+
80
+ def convert_info_to_obj(data: Information) -> Hospital:
81
+ """
82
+ Convert an Information object to a Hospital object.
83
+
84
+ Args:
85
+ data (Information): The Information object to convert.
86
+
87
+ Returns:
88
+ Hospital: A Hospital object constructed from the Information data.
89
+ """
90
+ data = to_dict(data) # Convert Information to dict for easier access
91
+
92
+ # Make doctor to patient map due to weakly linked patient data in the data dictionary
93
+ doctor_to_patient = dict()
94
+ for patient, patient_values in data['patient'].items():
95
+ doctor = patient_values['attending_physician']
96
+ if doctor in doctor_to_patient:
97
+ doctor_to_patient[doctor].append(patient)
98
+ else:
99
+ doctor_to_patient[doctor] = [patient]
100
+
101
+ hospital_obj = Hospital(**data.get('metadata'))
102
+ for department, department_values in data.get('department').items():
103
+ filtered_values1 = {k: v for k, v in department_values.items() if k != 'doctor'}
104
+ department_obj = hospital_obj.add_department(department, **filtered_values1)
105
+
106
+ for doctor in department_values['doctor']:
107
+ doctor_values = data.get('doctor').get(doctor)
108
+ filtered_values2 = {k: v for k, v in doctor_values.items() if k != 'department'}
109
+ doctor_obj = department_obj.add_doctor(doctor, **filtered_values2)
110
+ for patient in doctor_to_patient[doctor]:
111
+ patient_values = data.get('patient').get(patient)
112
+ filtered_values3 = {k: v for k, v in patient_values.items() if k != 'attending_physician'}
113
+ doctor_obj.add_patient(patient, **filtered_values3)
114
+
115
+ return hospital_obj
116
+
117
+
118
+
119
+ def convert_obj_to_info(hospital_obj: Hospital) -> Information:
120
+ """
121
+ Convert a Hospital object to an Information object.
122
+
123
+ Args:
124
+ hospital (Hospital): The Hospital object to convert.
125
+
126
+ Returns:
127
+ Information: An Information object constructed from the Hospital data.
128
+ """
129
+ filtered_values = {k: v for k, v in hospital_obj.__dict__.items() if k not in ['department', 'time']}
130
+ filtered_values['time'] = Information(**hospital_obj.time)
131
+ metadata = Information(**filtered_values)
132
+
133
+ department_info, doctor_info, patient_info = dict(), dict(), dict()
134
+ for department_obj in hospital_obj.department:
135
+ filtered_values = {k: v for k, v in department_obj.__dict__.items() if k not in ['name', 'doctor']}
136
+ filtered_values['doctor'] = [doctor_obj.name for doctor_obj in department_obj.doctor]
137
+ department_info[department_obj.name] = {**filtered_values}
138
+
139
+ for doctor_obj in department_obj.doctor:
140
+ filtered_values2 = {k: v for k, v in doctor_obj.__dict__.items() if k not in ['name', 'department', 'patient']}
141
+ filtered_values2['department'] = doctor_obj.department.name
142
+ doctor_info[doctor_obj.name] = {**filtered_values2}
143
+
144
+ for patient_obj in doctor_obj.patient:
145
+ filtered_values3 = {k: v for k, v in patient_obj.__dict__.items() if k not in ['name', 'department', 'attending_physician']}
146
+ filtered_values3['department'] = doctor_obj.department.name
147
+ filtered_values3['attending_physician'] = doctor_obj.name
148
+ patient_info[patient_obj.name] = {**filtered_values3}
149
+
150
+ return Information(metadata=metadata, department=department_info, doctor=doctor_info, patient=patient_info)
151
+
152
+
153
+
154
+ def str_to_datetime(iso_time: Union[str, datetime]) -> datetime:
155
+ """
156
+ Convert a string representation of a date/time to a datetime object, or return the input if it's already a datetime/date object.
157
+
158
+ Args:
159
+ iso_time (Union[str, datetime]): The input date/time, either as a string in the specified format or as a datetime/date object.
160
+
161
+ Raises:
162
+ ValueError: If `iso_time` is not a string or datetime/date object.
163
+
164
+ Returns:
165
+ datetime: A datetime object corresponding to the input string, or the original datetime/date if already provided.
166
+ """
167
+ try:
168
+ if isinstance(iso_time, str):
169
+ return datetime.fromisoformat(iso_time)
170
+ return iso_time
171
+ except:
172
+ raise ValueError(colorstr("red", f"`iso_time` must be str or date format, but got {type(iso_time)}"))
173
+
174
+
175
+
176
+ def datetime_to_str(iso_time: Union[str, datetime], format: str) -> str:
177
+ """
178
+ Convert a datetime object to a formatted string, or return the input if it is already a string.
179
+
180
+ Args:
181
+ iso_time (Union[str, datetime]): The input value to convert. If a datetime object, it will be formatted as a string.
182
+ If already a string, it will be returned as-is.
183
+ format (str): The format string used to convert the datetime object to a string
184
+ (e.g., "%Y-%m-%d" or "%Y-%m-%dT%H:%M:%S").
185
+
186
+ Raises:
187
+ ValueError: If `iso_time` is neither a string nor a datetime object.
188
+
189
+ Returns:
190
+ str: The formatted string representation of the datetime, or the original string if input was a string.
191
+ """
192
+ try:
193
+ if not isinstance(iso_time, str):
194
+ return iso_time.strftime(format)
195
+ return iso_time
196
+ except:
197
+ raise ValueError(colorstr("red", f"`iso_time` must be str or date format, but got {type(iso_time)}"))
198
+
199
+
200
+
201
+ def generate_date_range(start_date: Union[str, datetime],
202
+ days: int) -> list[str]:
203
+ """
204
+ Generate a list of dates starting from `start_date` for `days` days.
205
+
206
+ Args:
207
+ start_date (Union[str, datetime]): Start date in ISO format (YYYY-MM-DD).
208
+ days (int): Number of days to include (including start_date).
209
+
210
+ Returns:
211
+ List[str]: List of date strings in ISO format.
212
+ """
213
+ if days <= 0:
214
+ raise ValueError(colorstr("red", f"`days` must be larger than 0, but got {days}"))
215
+ start_date = str_to_datetime(start_date)
216
+ return [datetime_to_str(start_date + timedelta(days=i), "%Y-%m-%d") for i in range(days)]
217
+
218
+
219
+
220
+ def get_utc_offset(country_code: Optional[str] = None,
221
+ time_zone: Optional[str] = None) -> str:
222
+ """
223
+ Returns the current UTC offset (e.g., "+09:00") for a given country code or time zone.
224
+
225
+ Either `country_code` or `time_zone` must be provided. If the country has multiple time zones,
226
+ you should explicitly provide the `time_zone`.
227
+
228
+ Args:
229
+ country_code (Optional[str], optional): ISO 3166-1 alpha-2 country code (e.g., 'KR', 'US').
230
+ time_zone (Optional[str], optional): IANA time zone string (e.g., 'Asia/Seoul', 'America/New_York').
231
+
232
+ Returns:
233
+ str: The UTC offset of the specified time zone in the format "+HH:MM" or "-HH:MM".
234
+ """
235
+ assert not (country_code == None and time_zone == None), log("Either `country_code` or `time_zone` must be provided.", "error")
236
+
237
+ if registry.COUNTRY_TIMEZONE_MAP is None:
238
+ registry.COUNTRY_TIMEZONE_MAP = dict(pytz.country_timezones)
239
+
240
+ if country_code:
241
+ time_zones = registry.COUNTRY_TIMEZONE_MAP.get(country_code.upper())
242
+ time_zone = time_zone if len(time_zones) > 1 else time_zones[0] # If the country has mulitple time zone, you should use `time_zone` argument
243
+
244
+ time_zone = pytz.timezone(time_zone)
245
+ offset = datetime.now(time_zone).utcoffset()
246
+
247
+ # Convert offset to "+HH:MM" or "-HH:MM"
248
+ hours, remainder = divmod(offset.total_seconds(), 3600)
249
+ minutes = abs(remainder) // 60
250
+ return f'{int(hours):+03d}:{int(minutes):02d}'
251
+
252
+
253
+
254
+ def hour_to_hhmmss(hours: Union[int, float]) -> str:
255
+ """
256
+ Converts a decimal number of hours into HH:MM:SS format.
257
+
258
+ Args:
259
+ hours (Union[int, float]): A float or integer representing the number of hours.
260
+
261
+ Returns:
262
+ A string in the format "HH:MM:SS".
263
+ """
264
+ # Create a timedelta object from the given hours
265
+ td = timedelta(hours=hours)
266
+
267
+ # Extract total seconds from the timedelta object
268
+ total_seconds = int(td.total_seconds())
269
+
270
+ # Calculate hours, minutes, and seconds
271
+ h = total_seconds // 3600
272
+ m = (total_seconds % 3600) // 60
273
+ s = total_seconds % 60
274
+
275
+ # Format the output with zero-padding
276
+ return f'{h:02}:{m:02}:{s:02}'
277
+
278
+
279
+
280
+ def get_iso_time(time_hour: Union[int, float],
281
+ date: Optional[Union[str, datetime]] = None,
282
+ utc_offset: Optional[str] = None) -> str:
283
+ """
284
+ Construct an ISO 8601 time string from a given hour, optional date, and optional UTC offset.
285
+
286
+ Args:
287
+ time_hour (Union[int, float]): Time expressed in hours (e.g., 9.5 → 09:30:00).
288
+ date (Optional[Union[str, datetime]], optional): Date string in 'YYYY-MM-DD' format. Defaults to today's date.
289
+ utc_offset (Optional[str], optional): UTC offset in '+HH:MM' or '-HH:MM' format. Defaults to no offset.
290
+
291
+ Raises:
292
+ ValueError: If the `date` format is invalid.
293
+
294
+ Returns:
295
+ str: ISO 8601 formatted datetime string.
296
+ """
297
+ if date == None:
298
+ date = datetime_to_str(datetime.today(), '%Y-%m-%d')
299
+ else:
300
+ try:
301
+ date = datetime_to_str(date, '%Y-%m-%d')
302
+ datetime.strptime(date, '%Y-%m-%d')
303
+ except ValueError:
304
+ raise ValueError(colorstr("red", f"Invalid date format: '{date}'. Expected format is 'YYYY-MM-DD'."))
305
+
306
+ time = hour_to_hhmmss(time_hour)
307
+
308
+ if utc_offset:
309
+ return f'{date}T{time}{utc_offset}'
310
+ return f'{date}T{time}'
311
+
312
+
313
+
314
+ def iso_to_hour(iso_time: Union[str, datetime]) -> float:
315
+ """
316
+ Extract time information from an ISO 8601 time and convert time to float hour.
317
+
318
+ Args:
319
+ iso_time_str (Union[str, datetime]): ISO 8601 time string (e.g., '2025-07-17T09:30:00+09:00')
320
+
321
+ Returns:
322
+ float: Time represented in float hours (e.g., 9.5)
323
+ """
324
+ dt = str_to_datetime(iso_time)
325
+ hour = dt.hour
326
+ minute = dt.minute
327
+ second = dt.second
328
+
329
+ return hour + minute / 60 + second / 3600
330
+
331
+
332
+
333
+ def iso_to_date(iso_time: Union[str, datetime]) -> str:
334
+ """
335
+ Extract date information from an ISO 8601 time.
336
+
337
+ Args:
338
+ iso_time (Union[str, datetime]): ISO 8601 time string (e.g., '2025-07-17T09:30:00+09:00')
339
+
340
+ Returns:
341
+ str: Date represented in string (e.g. 2024-05-23)
342
+ """
343
+ iso_time = str_to_datetime(iso_time)
344
+ return str(iso_time.date())
345
+
346
+
347
+
348
+ def generate_random_iso_time_between(min_iso_time: Union[str, datetime],
349
+ max_iso_time: Union[str, datetime],
350
+ epsilon: float = 1e-6) -> str:
351
+ """
352
+ Generate a random ISO 8601 time string strictly within (min_iso_time, max_iso_time).
353
+
354
+ Args:
355
+ min_iso_time (Union[str, datetime]): The lower bound ISO 8601 time string (exclusive).
356
+ max_iso_time (Union[str, datetime]): The upper bound ISO 8601 time string (exclusive).
357
+ epsilon (float, optional): Small buffer to exclude both bounds. Defaults to 1e-6 seconds.
358
+
359
+ Raises:
360
+ ValueError: If min_iso_time is not earlier than max_iso_time or epsilon is too large.
361
+
362
+ Returns:
363
+ str: A randomly generated ISO 8601 time string within the specified range.
364
+ """
365
+ min_dt, max_dt = str_to_datetime(min_iso_time), str_to_datetime(max_iso_time)
366
+
367
+ if not compare_iso_time(max_dt, min_dt):
368
+ raise ValueError(colorstr("red", f"min_iso_time ({min_iso_time}) must be earlier than max_iso_time ({max_iso_time})"))
369
+
370
+ total_seconds = (max_dt - min_dt).total_seconds()
371
+
372
+ if total_seconds <= 2 * epsilon:
373
+ raise ValueError(colorstr("red", "Time range is too small for the given epsilon to exclude both bounds."))
374
+
375
+ # Exclude both bounds by starting from epsilon and ending at total_seconds - epsilon
376
+ random_seconds = random.uniform(epsilon, total_seconds - epsilon)
377
+ random_dt = min_dt + timedelta(seconds=random_seconds)
378
+
379
+ return random_dt.isoformat(timespec='seconds')
380
+
381
+
382
+
383
+ def compare_iso_time(time1: Union[str, datetime], time2: Union[str, datetime]) -> bool:
384
+ """
385
+ Compare two times given in ISO 8601 format and determine if the first is later than the second.
386
+
387
+ Args:
388
+ time1 (Union[str, datetime]): The first time value as an ISO 8601 string or a datetime object.
389
+ time2 (Union[str, datetime]): The second time value as an ISO 8601 string or a datetime object.
390
+
391
+ Returns:
392
+ bool: True if `time1` is later than `time2`, otherwise False.
393
+ """
394
+ return str_to_datetime(time1) > str_to_datetime(time2)
395
+
396
+
397
+
398
+ def generate_random_iso_date_between(min_date: Union[str, datetime],
399
+ max_date: Union[str, datetime]) -> str:
400
+ """
401
+ Generate a random date between min_date and max_date (inclusive).
402
+
403
+ Args:
404
+ min_date Union[str, datetime]: Minimum date in ISO format (YYYY-MM-DD) or a date object.
405
+ max_date Union[str, datetime]: Maximum date in ISO format (YYYY-MM-DD) or a date object.
406
+
407
+ Returns:
408
+ str: Random date in ISO format (YYYY-MM-DD).
409
+ """
410
+ min_date, max_date = str_to_datetime(min_date).date(), str_to_datetime(max_date).date()
411
+ delta_days = (max_date - min_date).days
412
+ if delta_days < 0:
413
+ raise ValueError(colorstr("red", "max_date must be after or equal to min_date."))
414
+
415
+ random_days = random.randint(0, delta_days)
416
+
417
+ return datetime_to_str(min_date + timedelta(days=random_days), "%Y-%m-%d")
418
+
419
+
420
+
421
+ def convert_time_to_segment(start: float,
422
+ end: float,
423
+ interval: float,
424
+ time_range: Optional[list[float]] = None) -> list[int]:
425
+ """
426
+ Generate time segment indices between a start and end time, using a specified interval.
427
+
428
+ This function divides a time range (e.g., 0 to 24 hours) into equal segments
429
+ and returns a list of integer indices representing those segments.
430
+
431
+ Args:
432
+ start (float): Start time in hours (e.g., 0.0 for 00:00).
433
+ end (float): End time in hours (e.g., 24.0 for 24:00).
434
+ interval (float): Time interval in hours (e.g., 0.5 for 30 minutes).
435
+ time_range (Optional[list[float]], optional): If provided, should be a list of two floats
436
+ [start_time, end_time]. Only segments within this subrange are returned.
437
+
438
+ Returns:
439
+ list[int]: List of segment indices, where each index corresponds to a time slot.
440
+
441
+ Example:
442
+ >>> convert_time_to_segment(0, 24, 0.5)
443
+ [0, 1, 2, ..., 47] # Represents 48 half-hour segments from 00:00 to 24:00
444
+ """
445
+ assert start < end, log("Start time must be less than end time", "error")
446
+ assert interval > 0, log("Interval must be greater than 0", "error")
447
+
448
+ getcontext().prec = 10
449
+ num_segments = int((end - start) / interval)
450
+
451
+ if time_range == None:
452
+ return list(range(num_segments))
453
+
454
+ # Sanity check for time_range
455
+ assert len(time_range) == 2, log("Time range must be composed of two float values", "error")
456
+ assert time_range[0] >= start and time_range[1] <= end, log("Time range must be within overall time bounds", "error")
457
+ assert time_range[0] < time_range[1], log("Start time of `time_range` must be less than its end time", "error")
458
+
459
+ start_idx = int((Decimal(str(time_range[0])) - Decimal(str(start))) / Decimal(str(interval)))
460
+ end_idx = int((Decimal(str(time_range[1])) - Decimal(str(start))) / Decimal(str(interval)))
461
+
462
+ return list(range(start_idx, end_idx))
463
+
464
+
465
+
466
+ def convert_segment_to_time(start: float,
467
+ end: float,
468
+ interval: float,
469
+ segments: list[int]) -> Tuple[float, float]:
470
+ """
471
+ Convert segment indices back to actual time values based on the given start time and interval.
472
+
473
+ Args:
474
+ start (float): Start time in hours (e.g., 0.0 for 00:00).
475
+ end (float): End time in hours (e.g., 24.0 for 24:00).
476
+ interval (float): Time interval in hours (e.g., 0.5 for 30 minutes).
477
+ segments (list[int]): List of segment indices to convert (e.g., [0, 1, 2]).
478
+
479
+ Returns:
480
+ list[float]: List of time values (in hours) corresponding to the given segments.
481
+
482
+ Example:
483
+ >>> convert_segment_to_time(0, 24, 0.5, [0, 1, 2])
484
+ [0.0, 1.5]
485
+ """
486
+ assert start < end, log("Start time must be less than end time", "error")
487
+ assert interval > 0, log("Interval must be greater than 0", "error")
488
+
489
+ getcontext().prec = 10
490
+ max_segments = int((end - start) / interval) - 1
491
+
492
+ # Sanity checking
493
+ for s in segments:
494
+ assert 0 <= s <= max_segments, log(f"Segment index {s} out of range", "error")
495
+
496
+ if len(segments) > 1:
497
+ for i in range(1, len(segments)):
498
+ assert segments[i] == segments[i-1] + 1, log("Segment indices must be continuous (i.e., increasing by 1)", "error")
499
+
500
+ seg_start = Decimal(str(start)) + Decimal(str(segments[0] * interval))
501
+ seg_end = min(Decimal(str(start)) + Decimal(str((segments[-1] + 1) * interval)), end)
502
+
503
+ return float(seg_start), float(seg_end)
504
+
505
+
506
+
507
+ def group_consecutive_segments(segments: list[int]) -> list[list[int]]:
508
+ """
509
+ Group a list of integer segments into consecutive blocks.
510
+
511
+ This function takes a list of integers and splits it into sublists where
512
+ each sublist contains consecutive numbers. Numbers that are not consecutive
513
+ start a new group.
514
+
515
+ Args:
516
+ segments (list[int]): A list of integer segments to be grouped.
517
+ The list should contain integers in any order;
518
+ typically sorted for meaningful consecutive grouping.
519
+
520
+ Returns:
521
+ list[list[int]]: A list of lists, where each sublist contains consecutive integers
522
+ from the input list.
523
+
524
+ Example:
525
+ >>> group_consecutive_segments([1, 2, 3, 5, 6, 8])
526
+ [[1, 2, 3], [5, 6], [8]]
527
+ """
528
+ consecutive_blocks = []
529
+ group = [segments[0]]
530
+ for i in range(1, len(segments)):
531
+ if segments[i] == segments[i - 1] + 1:
532
+ group.append(segments[i])
533
+ else:
534
+ consecutive_blocks.append(group)
535
+ group = [segments[i]]
536
+ consecutive_blocks.append(group)
537
+ return consecutive_blocks
538
+
539
+
540
+
541
+ def convert_time_list_to_merged_time(start: float,
542
+ end: float,
543
+ interval: float,
544
+ time_list: list[Tuple[float, float]]) -> list[Tuple[float, float]]:
545
+ """
546
+ Convert a list of time intervals into merged intervals based on a fixed segment grid.
547
+
548
+ This function maps each time interval in `time_list` into discrete segments
549
+ defined by the `start`, `end`, and `interval`. Consecutive segments are then
550
+ merged back into continuous time intervals.
551
+
552
+ Args:
553
+ start (float): The start time of the overall time range (e.g., 9.0 for 9:00 AM).
554
+ end (float): The end time of the overall time range (e.g., 17.0 for 5:00 PM).
555
+ interval (float): The length of each time segment (e.g., 0.5 for 30 minutes).
556
+ time_list (list[Tuple[float, float]]): A list of time intervals to convert and merge.
557
+ Each interval is a tuple (start_time, end_time).
558
+
559
+ Returns:
560
+ list[Tuple[float, float]]: A list of merged time intervals as tuples (start_time, end_time).
561
+ Intervals are merged if they cover consecutive segments.
562
+
563
+ Example:
564
+ >>> convert_time_list_to_merged_time(9.0, 17.0, 0.5, [(9.0, 10.0), (10.0, 11.0), (13.0, 14.0)])
565
+ [(9.0, 11.0), (13.0, 14.0)]
566
+ """
567
+ if len(time_list) > 0:
568
+ segments = sum([convert_time_to_segment(start, end, interval, t) for t in time_list], [])
569
+ grouped = group_consecutive_segments(segments)
570
+ time_list = [list(convert_segment_to_time(start, end, interval, group)) for group in grouped]
571
+ return time_list
572
+
573
+
574
+
575
+ def calculate_age(birthdate_str: str) -> int:
576
+ """
577
+ Calculate the current age in years based on a birthdate string.
578
+
579
+ Args:
580
+ birthdate_str (str): Birthdate in 'YYYY-MM-DD' format.
581
+
582
+ Returns:
583
+ int: Age in years as an integer.
584
+ """
585
+ birthdate = datetime.strptime(birthdate_str, "%Y-%m-%d").date()
586
+ today = datetime.today().date()
587
+
588
+ age = today.year - birthdate.year
589
+ # Subtract 1 if the birthday has not occurred yet this year
590
+ if (today.month, today.day) < (birthdate.month, birthdate.day):
591
+ age -= 1
592
+ return age
593
+
594
+
595
+
596
+ def sort_schedule(data: Union[dict, list]) -> Union[dict, list]:
597
+ """
598
+ Recursively sort schedule data for deterministic ordering.
599
+
600
+ Args:
601
+ data (Union[dict, list]): Schedule data to be sorted. Lists are sorted directly,
602
+ and dictionaries are sorted by keys with their values sorted.
603
+
604
+ Returns:
605
+ Union[dict, list]: Sorted schedule data with consistent ordering.
606
+ """
607
+ if isinstance(data, list):
608
+ return sorted(data)
609
+ return {k: sorted(v) for k, v in dict(sorted(data.items())).items()}
610
+
611
+
612
+
613
+ def personal_id_to_birth_date(personal_id: str,
614
+ start_date: str = "1960-01-01",
615
+ end_date: str = "2000-12-31") -> str:
616
+ """
617
+ Convert a personal ID (YYMMDD) to a birth date (YYYY-MM-DD).
618
+
619
+ Args:
620
+ personal_id (str): Personal ID in YYMMDD or YYMMDD-XXXX format.
621
+ start_date (str): Earliest possible birth date (YYYY-MM-DD).
622
+ end_date (str): Latest possible birth date (YYYY-MM-DD).
623
+
624
+ Returns:
625
+ str: Birth date in YYYY-MM-DD format.
626
+ """
627
+ yymmdd = personal_id.split("-")[0]
628
+
629
+ yy = int(yymmdd[:2])
630
+ mm = int(yymmdd[2:4])
631
+ dd = int(yymmdd[4:6])
632
+
633
+ start_year = datetime.fromisoformat(start_date).year
634
+ end_year = datetime.fromisoformat(end_date).year
635
+
636
+ # Century candidates (1900s, 2000s)
637
+ candidates = []
638
+ for century in (1900, 2000):
639
+ year = century + yy
640
+ try:
641
+ date = datetime(year, mm, dd)
642
+ if start_year <= year <= end_year:
643
+ candidates.append(date)
644
+ except ValueError:
645
+ continue
646
+
647
+ if not candidates:
648
+ return f"{random.choice(['19', '20'])}{yy}-{mm}-{dd}"
649
+
650
+ return candidates[0].strftime("%Y-%m-%d")
651
+
652
+
653
+
654
+ def init_result_dict() -> dict:
655
+ """
656
+ Initialize result dictionary.
657
+
658
+ Returns:
659
+ dict: Initialized result dictionary.
660
+ """
661
+ return {'gt': [], 'pred': [], 'status': [], 'status_code': [], 'trial': [], 'dialog': []}
662
+
663
+
664
+
665
+ def preprocess_dialog(dialog: dict) -> str:
666
+ """
667
+ Preprocess dialog data into a formatted string.
668
+
669
+ Args:
670
+ dialog (dict): Dialog data containing 'role' and 'content'.
671
+
672
+ Returns:
673
+ str: Pre-processed dialog string.
674
+ """
675
+ return '\n'.join([f"{turn['role']}: {' '.join(turn['content'].split())}" for turn in dialog])
676
+
677
+
678
+
679
+ def run_with_retry(func, *args, max_retries=8, **kwargs):
680
+ retry_count = 0
681
+
682
+ while 1:
683
+ try:
684
+ return func(*args, **kwargs)
685
+
686
+ except (ServerError, InternalServerError) as e:
687
+ if retry_count >= max_retries:
688
+ log(f"\nMax retries reached. Last error: {e}", level='error')
689
+ raise e
690
+
691
+ wait_time = exponential_backoff(retry_count)
692
+ log(
693
+ f"[{retry_count + 1}/{max_retries}] {type(e).__name__}: {e}. "
694
+ f"Retrying in {wait_time:.1f} seconds...",
695
+ level='warning',
696
+ )
697
+ time.sleep(wait_time)
698
+ retry_count += 1