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,222 @@
1
+ import requests
2
+ from typing import Optional
3
+ from urllib.parse import urlencode
4
+
5
+ from h_adminsim.utils import log
6
+
7
+
8
+
9
+ class FHIRManager:
10
+ def __init__(self, fhir_url):
11
+ self.fhir_url = fhir_url
12
+
13
+
14
+ def __logging(self, response: requests.Response, verbose=True) -> Optional[requests.Response]:
15
+ """
16
+ Log the response status code and content.
17
+
18
+ Args:
19
+ response (requests.Response): The HTTP response object.
20
+ verbose (bool, optional): If True, log details. Defaults to True.
21
+
22
+ Returns:
23
+ Optional[requests.Response]: The response object if JSON parsing is successful, else None.
24
+ """
25
+ if 200 <= response.status_code < 300:
26
+ if verbose:
27
+ log(f'Status code: {response.status_code}', color=True)
28
+ else:
29
+ if verbose:
30
+ log(f'Status code: {response.status_code}', level='error')
31
+
32
+ try:
33
+ response_json = response.json()
34
+ if verbose:
35
+ log(f'Response JSON: {response_json}')
36
+ except ValueError:
37
+ if verbose:
38
+ log(f'Response Text: {response.text}', level='error')
39
+ response = None
40
+ return response
41
+
42
+
43
+ def create(self,
44
+ resource_type: str,
45
+ resource_data: dict,
46
+ headers: Optional[dict] = None,
47
+ verbose: bool = True) -> Optional[requests.Response]:
48
+ """
49
+ Create a FHIR resource of the specified type.
50
+
51
+ Args:
52
+ resource_type (str): FHIR resource type (e.g., "Patient", "PractitionerRole").
53
+ resource_data (dict): FHIR resource data as a dictionary.
54
+ headers (Optional[dict], optional): HTTP headers to use. Defaults to None.
55
+ verbose (bool, optional): If True, log details. Defaults to True.
56
+
57
+ Returns:
58
+ Optional[requests.Response]: The HTTP response object if JSON parsing is successful, else None.
59
+ """
60
+ _id = resource_data.get('id')
61
+ fhir_url = f'{self.fhir_url}/{resource_type}/{_id}'
62
+ response = requests.put(
63
+ fhir_url,
64
+ headers={'Content-Type': 'application/fhir+json'} if headers is None else headers,
65
+ json=resource_data,
66
+ )
67
+
68
+ # Log and return the response
69
+ return self.__logging(response, verbose)
70
+
71
+
72
+ def read(self,
73
+ resource_type: str,
74
+ id: str,
75
+ headers: Optional[dict] = None,
76
+ verbose: bool = True) -> Optional[requests.Response]:
77
+ """
78
+ Read a FHIR resource of the specified type and ID.
79
+
80
+ Args:
81
+ resource_type (str): FHIR resource type (e.g., "Patient", "PractitionerRole").
82
+ id (str): The ID of the FHIR resource to read.
83
+ headers (Optional[dict], optional): HTTP headers to use. Defaults to None.
84
+ verbose (bool, optional): If True, log details. Defaults to True.
85
+
86
+ Returns:
87
+ Optional[requests.Response]: The HTTP response object if JSON parsing is successful, else None.
88
+ """
89
+ fhir_url = f'{self.fhir_url}/{resource_type}/{id}'
90
+ response = requests.get(
91
+ fhir_url,
92
+ headers={'Accept': 'application/fhir+json'} if headers is None else headers,
93
+ )
94
+
95
+ # Log and return the response
96
+ return self.__logging(response, verbose)
97
+
98
+
99
+ def update(self,
100
+ resource_type: str,
101
+ id: str,
102
+ resource_data: dict,
103
+ headers: Optional[dict] = None,
104
+ verbose: bool = True) -> Optional[requests.Response]:
105
+ """
106
+ Update a FHIR resource of the specified type and ID.
107
+
108
+ Args:
109
+ resource_type (str): FHIR resource type (e.g., "Patient", "PractitionerRole").
110
+ id (str): The ID of the FHIR resource to update.
111
+ resource_data (dict): FHIR resource data as a dictionary.
112
+ headers (Optional[dict], optional): HTTP headers to use. Defaults to None.
113
+ verbose (bool, optional): If True, log details. Defaults to True.
114
+
115
+ Returns:
116
+ Optional[requests.Response]: _description_
117
+ """
118
+ fhir_url = f'{self.fhir_url}/{resource_type}/{id}'
119
+ response = requests.put(
120
+ fhir_url,
121
+ headers={'Content-Type': 'application/fhir+json'} if headers is None else headers,
122
+ json=resource_data,
123
+ )
124
+
125
+ # Log and return the response
126
+ return self.__logging(response, verbose)
127
+
128
+
129
+ def delete(self, resource_type: str, id: str, verbose=True):
130
+ fhir_url = f'{self.fhir_url}/{resource_type}/{id}'
131
+ response = requests.delete(
132
+ fhir_url
133
+ )
134
+
135
+ # Log and return the response
136
+ return self.__logging(response, verbose)
137
+
138
+
139
+ def read_all(self,
140
+ resource_type: str,
141
+ headers: Optional[dict] = None,
142
+ count: int = 100,
143
+ verbose: bool = True,
144
+ params: Optional[dict] = None) -> list[dict]:
145
+ """
146
+ Read all resources of a given resource type using FHIR search with optional filtering.
147
+
148
+ Args:
149
+ resource_type (str): FHIR resource type (e.g., "PractitionerRole").
150
+ headers (dict, optional): HTTP headers to use.
151
+ count (int): Number of resources to fetch per page (default: 100).
152
+ verbose (bool): If True, log each response. Defaults to True.
153
+ params (dict, optional): FHIR search parameters (e.g., {"specialty": "IMALL-2"}).
154
+
155
+ Returns:
156
+ list[dict]: List of bundle entry dicts.
157
+ """
158
+ all_entries = []
159
+ headers = {'Accept': 'application/fhir+json'} if headers is None else headers
160
+
161
+ # Build first page URL with params
162
+ q = {'_count': count}
163
+ if params:
164
+ q.update({k: v for k, v in params.items() if v is not None})
165
+ url = f"{self.fhir_url}/{resource_type}?{urlencode(q, doseq=True)}"
166
+
167
+ while url:
168
+ response = requests.get(url, headers=headers)
169
+ self.__logging(response, verbose)
170
+ try:
171
+ bundle = response.json()
172
+ except Exception:
173
+ break
174
+
175
+ if bundle.get('resourceType') != 'Bundle' or 'entry' not in bundle:
176
+ break
177
+
178
+ all_entries.extend(bundle['entry'])
179
+
180
+ # Check for next link (pagination)
181
+ next_link = next(
182
+ (link.get('url') for link in bundle.get('link', []) if link.get('relation') == 'next'),
183
+ None
184
+ )
185
+ url = next_link # Continue if next page exists, else break
186
+
187
+ return all_entries
188
+
189
+
190
+ def delete_all(self, entry: list[dict], verbose: bool = True):
191
+ """
192
+ Delete all FHIR resources from a given list of resource entries.
193
+
194
+ Args:
195
+ entry (list[dict]): List of FHIR Bundle entries, typically from the `read_all()` method.
196
+ Each entry should contain a 'resource' dict with 'resourceType' and 'id'.
197
+ verbose (bool): If True, log each deletion response. Defaults to True.
198
+ """
199
+ error_ids = list()
200
+
201
+ for resource in entry:
202
+ resource_type = resource.get('resource').get('resourceType')
203
+ id = resource.get('resource').get('id')
204
+ response = self.delete(resource_type, id, verbose)
205
+
206
+ if not 200 <= response.status_code < 300:
207
+ error_ids.append(id)
208
+
209
+ if error_ids:
210
+ log(f'Error(s) occurs during delete resources: {error_ids}', 'warning')
211
+ else:
212
+ log('Deletion successfully completed', color=True)
213
+
214
+
215
+
216
+
217
+ # PostgreSQL
218
+ # docker exec -it jmlee_fhir_db psql -U admin -d hapi
219
+ # SELECT * FROM hfj_resource WHERE res_type = 'Patient' LIMIT 1;
220
+ # SELECT * FROM HFJ_RES_VER WHERE RES_ID = 925754;
221
+
222
+
@@ -0,0 +1,151 @@
1
+ import random
2
+ from typing import Tuple, Optional
3
+
4
+ from h_adminsim.utils.common_utils import (
5
+ convert_segment_to_time,
6
+ convert_time_to_segment,
7
+ group_consecutive_segments,
8
+ )
9
+
10
+
11
+
12
+ class ScheduleAssigner:
13
+ def __init__(self, start: float, end: float, interval: float):
14
+ """
15
+ Initialize a ScheduleAssigner for generating random schedules or appointments.
16
+
17
+ This class divides a given time range into fixed-size segments and provides methods
18
+ to assign schedules or appointments by selecting and grouping these segments.
19
+
20
+ Args:
21
+ start (float): Start time in hours (e.g., 9.0 for 09:00).
22
+ end (float): End time in hours (e.g., 18.0 for 18:00).
23
+ interval (float): Time interval in hours for each segment (e.g., 0.5 for 30 minutes).
24
+ """
25
+ self.start = start
26
+ self.end = end
27
+ self.interval = interval
28
+ self.segments = convert_time_to_segment(self.start, self.end, self.interval)
29
+
30
+
31
+ def schedule_segment_assign(self,
32
+ p: float,
33
+ segments: Optional[list[int]] = None) -> list[list[int]]:
34
+ """
35
+ Randomly assign a proportion of schedule time segments into grouped consecutive blocks.
36
+
37
+ This method selects a random subset of segments, where the number of segments is
38
+ determined by the proportion `p` (e.g., 0.5 means 50% of all segments).
39
+ The selected segments are then grouped into lists of consecutive segment indices.
40
+
41
+ Args:
42
+ p (float): Proportion of total segments to assign, between 0 and 1.
43
+ segments (Optional[list[int]], optional): Specific segments. Defaults to None.
44
+
45
+ Returns:
46
+ list[list[int]]: A list of groups, where each group is a list of consecutive segment indices.
47
+ For example, [[0, 1], [3, 4, 5], [7]].
48
+
49
+ Example:
50
+ If segments = [0, 1, 2, ..., 11] and p = 0.5,
51
+ this function might return something like:
52
+ [[0, 1], [4], [6, 7, 8]]
53
+
54
+ Notes:
55
+ - The segment indices are selected randomly each time the function is called.
56
+ - Groups are always composed of consecutive indices from the selected subset.
57
+ """
58
+ segments = self.segments if segments == None else segments
59
+ segment_n = round(len(segments) * p)
60
+
61
+ if segment_n > 0:
62
+ # Select random segments
63
+ chosen_segments = random.sample(segments, segment_n)
64
+ chosen_segments.sort()
65
+
66
+ # Grouping consecutive segments
67
+ grouped = group_consecutive_segments(chosen_segments)
68
+ return grouped
69
+ return []
70
+
71
+
72
+ def appointment_segment_assign(self,
73
+ p: float,
74
+ min_chunk_size: int,
75
+ max_chunk_size: int,
76
+ segments: Optional[list[int]] = None) -> list[list[int]]:
77
+ """
78
+ Randomly assign appointment time segments from the remaining (unassigned) segments.
79
+
80
+ Args:
81
+ p (float): Proportion of remaining segments to sample and assign.
82
+ min_chunk_size (int): The minimum time segment size for each appointment.
83
+ max_chunk_size (int): The maximum time segment size for each appointment.
84
+ segments (Optional[list[int]], optional): Specific segments. Defaults to None.
85
+
86
+ Returns:
87
+ list[list[int]]: Newly assigned segments from the remaining pool, grouped consecutively.
88
+ """
89
+ segments = self.segments if segments == None else segments
90
+ avg_chunk_size = (min_chunk_size + max_chunk_size) / 2
91
+ segment_n = int(len(segments) * p // avg_chunk_size)
92
+
93
+ if segment_n > 0:
94
+ chosen = []
95
+ used = set()
96
+ max_index = max(segments)
97
+ random.shuffle(segments)
98
+
99
+ for s in segments:
100
+ max_possible_size = min(max_chunk_size, max_index - s + 1)
101
+ if max_possible_size < min_chunk_size:
102
+ continue
103
+
104
+ chunk_size = random.randint(min_chunk_size, max_chunk_size)
105
+ trip = set(range(s, s + chunk_size))
106
+
107
+ if used.isdisjoint(trip):
108
+ chosen.append((s, chunk_size))
109
+ used |= trip
110
+ if len(chosen) == segment_n:
111
+ break
112
+
113
+ return [list(range(start, start + size)) for start, size in sorted(chosen)]
114
+ return []
115
+
116
+
117
+ def __call__(self,
118
+ p: float,
119
+ is_appointment: bool = False,
120
+ segments: Optional[list[list[int]]] = None,
121
+ **kwargs) -> Tuple[list[list[int]], list[list[float]]]:
122
+ """
123
+ Generate grouped time ranges by randomly selecting and grouping a proportion of time segments.
124
+
125
+ This method allows the ScheduleAssigner instance to be called directly with a proportion `p`.
126
+ It selects a subset of time segments based on `p`, groups them into consecutive segment blocks,
127
+ and converts each group of segment indices into their corresponding time values.
128
+
129
+ Args:
130
+ p (float): Proportion of total segments to select, between 0 and 1.
131
+ is_appointment (bool, optional): Whether the generated schedules are for appointments. Defaults to False.
132
+ segments (Optional[list[list[int]]], optional): Specific segemnts. Defaults to None.
133
+
134
+ Returns:
135
+ list[list[float]]: A list of grouped time segments. Each group is a list of time segemnt values.
136
+ For example: [[2, 3], [5, 6, 7]].
137
+ list[list[float]]: A list of grouped time ranges. Each group is a list of time values (in hours),
138
+ corresponding to consecutive time segments.
139
+ For example: [[0.0, 0.5], [2.0, 3.0]].
140
+
141
+ Example:
142
+ >>> assigner = ScheduleAssigner(0, 12, 0.5)
143
+ >>> assigner(0.25)
144
+ [[1.0, 1.5], [4.5], [6.0, 6.5]]
145
+ """
146
+ time_segments = self.appointment_segment_assign(p, segments=segments, **kwargs) \
147
+ if is_appointment else self.schedule_segment_assign(p, segments)
148
+
149
+ if len(time_segments):
150
+ return time_segments, [list(convert_segment_to_time(self.start, self.end, self.interval, segments)) for segments in time_segments]
151
+ return [], []
@@ -0,0 +1,5 @@
1
+ from .scheduling_rule import *
2
+ from .data_converter import DataConverter
3
+ from .agent_data_builder import AgentDataBuilder
4
+ from .data_synthesizer import DataSynthesizer
5
+ from .evaluator import Evaluator
@@ -0,0 +1,124 @@
1
+ import os
2
+ from tqdm import tqdm
3
+ from typing import Optional
4
+ from decimal import getcontext
5
+ from importlib import resources
6
+
7
+ from h_adminsim.utils.fhir_utils import *
8
+ from h_adminsim.utils.random_utils import generate_random_symptom
9
+ from h_adminsim.utils.filesys_utils import json_load, json_save_fast, get_files
10
+
11
+
12
+
13
+ class AgentDataBuilder:
14
+ def __init__(self, config):
15
+ # Initialize configuration
16
+ data_dir = os.path.join(config.project, config.data_name, 'data')
17
+ self.data_files = get_files(data_dir, ext='json')
18
+ getcontext().prec = 10
19
+
20
+
21
+ @staticmethod
22
+ def build(data: dict,
23
+ save_path: Optional[str] = None,
24
+ symptom_file_path: Optional[str] = None) -> dict:
25
+ """
26
+ Build agent test data from a single hospital data entry.
27
+
28
+ Args:
29
+ data (dict): Dictionary containing metadata, departments, doctors, and patients.
30
+ save_path (Optional[str], optional): If provided, the generated agent data will be saved to this path.
31
+ symptom_file_path (str, optional): Path to the JSON file containing symptoms per department. Defaults to None.
32
+
33
+ Returns:
34
+ dict: A dictionary with the following keys:
35
+ - 'metadata': Original metadata from input.
36
+ - 'department': Department-level information.
37
+ - 'doctor': Doctor-level information.
38
+ - 'agent_data': List of tuples. Each tuple consists of:
39
+ - Ground-truth scheduling information.
40
+ - Agent input (symptom and constraints).
41
+ """
42
+ if symptom_file_path == None:
43
+ symptom_file_path = str(resources.files("h_adminsim.assets.departments").joinpath("symptom.json"))
44
+
45
+ agent_data = {'metadata': data['metadata'], 'department': data['department'], 'doctor': data['doctor'], 'agent_data': []}
46
+
47
+ for patient, patient_values in data['patient'].items():
48
+ doctor, department, date = patient_values['attending_physician'], patient_values['department'], patient_values['date']
49
+ gender, telecom, birth_date, identifier, address = \
50
+ patient_values['gender'], patient_values['telecom'], patient_values['birthDate'], patient_values['identifier'], patient_values['address']
51
+ preference, symptom_level = patient_values['preference'], patient_values['symptom_level']
52
+ disease = generate_random_symptom(
53
+ department=department,
54
+ symptom_file_path=symptom_file_path,
55
+ ensure_unique_department='doctor' in patient_values['preference']
56
+ )
57
+ gt_department = disease['department'] if isinstance(disease, dict) else [department]
58
+ gt = {
59
+ 'patient': patient,
60
+ 'gender': gender,
61
+ 'telecom': telecom,
62
+ 'birthDate': birth_date,
63
+ 'identifier': identifier,
64
+ 'address': address,
65
+ 'department': gt_department,
66
+ 'attending_physician': doctor,
67
+ 'valid_from': date if 'date' in preference else 'N/A',
68
+ 'preference': preference,
69
+ 'symptom_level': symptom_level,
70
+ }
71
+ agent = {
72
+ 'patient': patient,
73
+ 'gender': gender,
74
+ 'telecom': telecom,
75
+ 'birthDate': birth_date,
76
+ 'identifier': identifier,
77
+ 'address': address,
78
+ 'constraint': {
79
+ 'preference': preference,
80
+ 'attending_physician': doctor,
81
+ 'valid_from': date if 'date' in preference else 'N/A',
82
+ 'symptom_level': symptom_level,
83
+ 'symptom': disease,
84
+ }
85
+ }
86
+ agent_data['agent_data'].append((gt, agent))
87
+
88
+ if save_path:
89
+ json_save_fast(
90
+ save_path,
91
+ agent_data
92
+ )
93
+
94
+ return agent_data
95
+
96
+
97
+ def __call__(self,
98
+ output_dir: Optional[str] = None,
99
+ symptom_file_path: Optional[str] = None) -> list[dict]:
100
+ """
101
+ Generate agent test datasets for all input data files.
102
+
103
+ Args:
104
+ output_dir (Optional[str], optional): Directory to save the generated agent data files.
105
+ If not provided, files are not saved.
106
+ symptom_file_path (Optional[str], optional): Path to the symptom file used during agent construction. Defaults to None.
107
+
108
+ Returns:
109
+ list[dict]: A list of agent test data dictionaries, one for each processed input file.
110
+ """
111
+ if symptom_file_path == None:
112
+ symptom_file_path = str(resources.files("h_adminsim.assets.departments").joinpath("symptom.json"))
113
+
114
+ os.makedirs(output_dir, exist_ok=True)
115
+ all_agent_data = list()
116
+
117
+ for data_file in tqdm(self.data_files, desc='Generating data for agent simulation..'):
118
+ data = json_load(data_file)
119
+ basename, ext = os.path.splitext(os.path.basename(data_file))
120
+ agent_data = AgentDataBuilder.build(data, os.path.join(output_dir, f"{basename}_agent{ext}"), symptom_file_path)
121
+ all_agent_data.append(agent_data)
122
+
123
+ return all_agent_data
124
+