LearningnRunning commited on
Commit
41a320c
Β·
1 Parent(s): 924ef30

feat: Initialize nursing handoff education platform with Gradio, PostgreSQL setup, and initial database scripts. Added .gitignore, Docker configuration, and project metadata.

Browse files
Files changed (6) hide show
  1. .gitignore +27 -0
  2. app.py +878 -0
  3. docker-compose.yml +23 -0
  4. init_db.py +23 -0
  5. pyproject.toml +12 -0
  6. start.sh +24 -0
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+
4
+ # Python
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+ .Python
10
+ .venv/
11
+ venv/
12
+ ENV/
13
+
14
+ # IDE
15
+ .vscode/
16
+ .idea/
17
+ *.swp
18
+ *.swo
19
+
20
+ # Database
21
+ *.db
22
+ *.sqlite3
23
+
24
+ # OS
25
+ .DS_Store
26
+ Thumbs.db
27
+
app.py ADDED
@@ -0,0 +1,878 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ - Gradio λ©€ν‹°νŽ˜μ΄μ§€ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ (μ±„νŒ… 기반)
3
+ """
4
+
5
+ import gradio as gr
6
+ from config.database import SessionLocal, init_db
7
+ from services.db_service import DatabaseService, load_scenarios_to_db
8
+ from services.gemini_service import GeminiEvaluator
9
+
10
+ # λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™”
11
+ try:
12
+ init_db()
13
+ print("βœ… λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” μ™„λ£Œ")
14
+
15
+ # μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ
16
+ db = SessionLocal()
17
+ load_scenarios_to_db(db, "data/scenarios.json")
18
+ db.close()
19
+ print("βœ… μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ μ™„λ£Œ")
20
+ except Exception as e:
21
+ print(f"⚠️ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 쀑 였λ₯˜: {e}")
22
+ print("ℹ️ Docker Compose둜 PostgreSQL을 λ¨Όμ € μ‹€ν–‰ν•΄μ£Όμ„Έμš”: docker-compose up -d")
23
+
24
+ # Gemini 평가기 μ΄ˆκΈ°ν™”
25
+ evaluator = GeminiEvaluator()
26
+
27
+
28
+ # ===== EMR HTML 생성 ν•¨μˆ˜λ“€ (κΈ°μ‘΄ 둜직 μž¬μ‚¬μš©) =====
29
+
30
+ def create_emr_html(patient, scenario):
31
+ """μ‹€μ œ 병원 EMRκ³Ό μœ μ‚¬ν•œ HTML 생성"""
32
+ vitals = scenario.vitals or {}
33
+ labs = scenario.labs or {}
34
+ orders = scenario.orders or []
35
+ nursing_notes = scenario.nursing_notes or []
36
+
37
+ # EMR 헀더
38
+ emr_html = f"""
39
+ <div style="font-family: 'Malgun Gothic', sans-serif; background: #f5f5f5; padding: 15px; border-radius: 8px;">
40
+ <!-- ν™˜μž κΈ°λ³Έ 정보 헀더 -->
41
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
42
+ <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px;">
43
+ <div>
44
+ <div style="font-size: 11px; opacity: 0.9;">ν™˜μžλͺ… / λ“±λ‘λ²ˆν˜Έ</div>
45
+ <div style="font-size: 16px; font-weight: bold; margin-top: 5px;">{patient.name} / {patient.id}</div>
46
+ </div>
47
+ <div>
48
+ <div style="font-size: 11px; opacity: 0.9;">λ‚˜μ΄/성별 / 병싀</div>
49
+ <div style="font-size: 16px; font-weight: bold; margin-top: 5px;">{patient.age}μ„Έ / {patient.gender} / {patient.room_number}</div>
50
+ </div>
51
+ <div>
52
+ <div style="font-size: 11px; opacity: 0.9;">μž…μ›μΌ / μž¬μ›μΌμˆ˜</div>
53
+ <div style="font-size: 16px; font-weight: bold; margin-top: 5px;">{patient.admission_date} / D+{scenario.hospital_day}</div>
54
+ </div>
55
+ </div>
56
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255,255,255,0.3);">
57
+ <div style="font-size: 11px; opacity: 0.9;">진단λͺ…</div>
58
+ <div style="font-size: 14px; font-weight: bold; margin-top: 5px;">{patient.diagnosis}</div>
59
+ </div>
60
+ </div>
61
+
62
+ <!-- μ•Œλ ˆλ₯΄κΈ° / κ²½κ³  -->
63
+ <div style="background: {'#fff3cd' if patient.allergies != 'μ—†μŒ' else '#d4edda'}; border-left: 4px solid {'#ffc107' if patient.allergies != 'μ—†μŒ' else '#28a745'}; padding: 12px; margin-bottom: 15px; border-radius: 4px;">
64
+ <strong>⚠️ μ•Œλ ˆλ₯΄κΈ°:</strong> {patient.allergies} &nbsp;&nbsp;|&nbsp;&nbsp;
65
+ <strong>πŸ“‹ κ³Όκ±°λ ₯:</strong> {patient.comorbidities} &nbsp;&nbsp;|&nbsp;&nbsp;
66
+ <strong>πŸ‘¨β€βš•οΈ λ‹΄λ‹Ήμ˜:</strong> {patient.attending_physician}
67
+ </div>
68
+
69
+ <!-- ν™œλ ₯μ§•ν›„ νŒ¨λ„ -->
70
+ <div style="background: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);">
71
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea;">
72
+ πŸ“Š ν™œλ ₯μ§•ν›„ <span style="font-size: 12px; color: #666; font-weight: normal;">({vitals.get('time', 'N/A')})</span>
73
+ </div>
74
+ <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
75
+ {create_vital_card('ν˜ˆμ••', 'BP', vitals.get('BP', 'N/A'), '100-140/60-90', vitals.get('BP', ''))}
76
+ {create_vital_card('λ§₯λ°•', 'HR', vitals.get('HR', 'N/A'), '60-100', vitals.get('HR', ''))}
77
+ {create_vital_card('호흑', 'RR', vitals.get('RR', 'N/A'), '12-20', vitals.get('RR', ''))}
78
+ {create_vital_card('체온', 'BT', vitals.get('BT', 'N/A'), '36.0-37.5', vitals.get('BT', ''))}
79
+ {create_vital_card('μ‚°μ†Œν¬ν™”λ„', 'SpO2', vitals.get('SpO2', 'N/A'), 'β‰₯95%', vitals.get('SpO2', ''))}
80
+ {create_vital_card('톡증', 'Pain', vitals.get('Pain', 'N/A'), '0-3', vitals.get('Pain', ''))}
81
+ </div>
82
+ </div>
83
+
84
+ <!-- 검사 κ²°κ³Ό νŒ¨λ„ -->
85
+ <div style="background: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);">
86
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea;">
87
+ πŸ§ͺ 검사 κ²°κ³Ό <span style="font-size: 12px; color: #666; font-weight: normal;">({labs.get('time', 'N/A')})</span>
88
+ </div>
89
+ {create_lab_results_table(labs)}
90
+ </div>
91
+
92
+ <!-- μ˜μ‚¬ 처방 νŒ¨λ„ -->
93
+ <div style="background: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);">
94
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea;">
95
+ πŸ’Š μ˜μ‚¬ 처방
96
+ </div>
97
+ <div style="font-size: 13px; line-height: 1.8;">
98
+ {create_orders_list(orders)}
99
+ </div>
100
+ </div>
101
+
102
+ <!-- κ°„ν˜Έ 기둝 νŒ¨λ„ -->
103
+ <div style="background: white; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);">
104
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #667eea;">
105
+ πŸ“ κ°„ν˜Έ 기둝
106
+ </div>
107
+ <div style="font-size: 13px;">
108
+ {create_nursing_notes_timeline(nursing_notes)}
109
+ </div>
110
+ </div>
111
+
112
+ {create_surgery_info_panel(scenario) if scenario.surgery_info else ''}
113
+ {create_discharge_education_panel(scenario) if scenario.discharge_education else ''}
114
+ </div>
115
+ """
116
+ return emr_html
117
+
118
+
119
+ def create_vital_card(label, code, value, normal_range, raw_value):
120
+ """ν™œλ ₯μ§•ν›„ μΉ΄λ“œ 생성"""
121
+ is_abnormal = check_vital_abnormal(code, raw_value)
122
+ bg_color = "#ffe6e6" if is_abnormal else "#e8f5e9"
123
+ border_color = "#f44336" if is_abnormal else "#4caf50"
124
+ icon = "⚠️" if is_abnormal else "βœ“"
125
+
126
+ return f"""
127
+ <div style="background: {bg_color}; border-left: 4px solid {border_color}; padding: 12px; border-radius: 4px; text-align: center;">
128
+ <div style="font-size: 11px; color: #666; margin-bottom: 4px;">{label}</div>
129
+ <div style="font-size: 18px; font-weight: bold; color: #333; margin: 6px 0;">{icon} {value}</div>
130
+ <div style="font-size: 10px; color: #888;">정상: {normal_range}</div>
131
+ </div>
132
+ """
133
+
134
+
135
+ def check_vital_abnormal(code, value_str):
136
+ """ν™œλ ₯μ§•ν›„ 이상 μ—¬λΆ€ 체크"""
137
+ if not value_str or value_str == "N/A":
138
+ return False
139
+
140
+ import re
141
+ numbers = re.findall(r"\d+\.?\d*", value_str)
142
+ if not numbers:
143
+ return False
144
+
145
+ try:
146
+ if code == "BP":
147
+ bp_values = re.findall(r"(\d+)/(\d+)", value_str)
148
+ if bp_values:
149
+ systolic = int(bp_values[0][0])
150
+ diastolic = int(bp_values[0][1])
151
+ return systolic < 90 or systolic > 140 or diastolic < 60 or diastolic > 90
152
+ elif code == "HR":
153
+ hr = int(numbers[0])
154
+ return hr < 60 or hr > 100
155
+ elif code == "RR":
156
+ rr = int(numbers[0])
157
+ return rr < 12 or rr > 20
158
+ elif code == "BT":
159
+ bt = float(numbers[0])
160
+ return bt < 36.0 or bt > 37.5
161
+ elif code == "SpO2":
162
+ spo2 = int(numbers[0])
163
+ return spo2 < 95
164
+ elif code == "Pain":
165
+ pain = int(numbers[0])
166
+ return pain > 3
167
+ except (ValueError, IndexError):
168
+ pass
169
+
170
+ return False
171
+
172
+
173
+ def create_lab_results_table(labs):
174
+ """검사 κ²°κ³Ό ν…Œμ΄λΈ” 생성"""
175
+ if not labs:
176
+ return "<div style='color: #999; text-align: center; padding: 20px;'>검사 κ²°κ³Ό μ—†μŒ</div>"
177
+
178
+ html = '<table style="width: 100%; border-collapse: collapse; font-size: 12px;">'
179
+ html += '<thead><tr style="background: #f8f9fa; font-weight: bold;">'
180
+ html += '<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">검사항λͺ©</th>'
181
+ html += '<th style="padding: 10px; text-align: center; border-bottom: 2px solid #dee2e6;">κ²°κ³Ό</th>'
182
+ html += '<th style="padding: 10px; text-align: center; border-bottom: 2px solid #dee2e6;">μ°Έκ³ λ²”μœ„</th>'
183
+ html += '<th style="padding: 10px; text-align: center; border-bottom: 2px solid #dee2e6;">μƒνƒœ</th>'
184
+ html += "</tr></thead><tbody>"
185
+
186
+ # CBC κ²°κ³Ό
187
+ if "CBC" in labs:
188
+ cbc = labs["CBC"]
189
+ html += create_lab_row("WBC", cbc.get("WBC", "N/A"), "4.0-10.0 Γ—10Β³/Β΅L", check_lab_abnormal("WBC", cbc.get("WBC", "")))
190
+ html += create_lab_row("Hemoglobin", cbc.get("Hb", "N/A"), "13.0-17.0 g/dL (M)", check_lab_abnormal("Hb", cbc.get("Hb", "")))
191
+ html += create_lab_row("Platelet", cbc.get("Plt", "N/A"), "150-400 Γ—10Β³/Β΅L", False)
192
+ html += create_lab_row("Neutrophil", cbc.get("Neutrophil", "N/A"), "40-70%", False)
193
+
194
+ # CRP
195
+ if "CRP" in labs:
196
+ html += create_lab_row("CRP", labs["CRP"], "<0.5 mg/dL", check_lab_abnormal("CRP", labs["CRP"]))
197
+
198
+ # μ „ν•΄μ§ˆ
199
+ if "BMP" in labs:
200
+ bmp = labs["BMP"]
201
+ html += create_lab_row("Sodium", f"{bmp.get('Na', 'N/A')} mmol/L", "136-145 mmol/L", False)
202
+ html += create_lab_row("Potassium", f"{bmp.get('K', 'N/A')} mmol/L", "3.5-5.0 mmol/L", False)
203
+ html += create_lab_row("Creatinine", f"{bmp.get('Cr', 'N/A')} mg/dL", "0.7-1.3 mg/dL", False)
204
+
205
+ html += "</tbody></table>"
206
+ return html
207
+
208
+
209
+ def create_lab_row(name, value, ref_range, is_abnormal):
210
+ """검사 κ²°κ³Ό ν–‰ 생성"""
211
+ status_icon = "πŸ”΄" if is_abnormal else "🟒"
212
+ status_text = "비정상" if is_abnormal else "정상"
213
+ row_bg = "#fff3f3" if is_abnormal else "white"
214
+
215
+ return f"""
216
+ <tr style="background: {row_bg}; border-bottom: 1px solid #f0f0f0;">
217
+ <td style="padding: 10px;">{name}</td>
218
+ <td style="padding: 10px; text-align: center; font-weight: bold;">{value}</td>
219
+ <td style="padding: 10px; text-align: center; color: #666; font-size: 11px;">{ref_range}</td>
220
+ <td style="padding: 10px; text-align: center;">{status_icon} {status_text}</td>
221
+ </tr>
222
+ """
223
+
224
+
225
+ def check_lab_abnormal(lab_name, value_str):
226
+ """검사 κ²°κ³Ό 이상 μ—¬λΆ€ 체크"""
227
+ if "↑" in value_str or "↓" in value_str or "μƒμŠΉ" in value_str or "κ°μ†Œ" in value_str:
228
+ return True
229
+ return False
230
+
231
+
232
+ def create_orders_list(orders):
233
+ """μ˜μ‚¬ 처방 λͺ©λ‘ 생성"""
234
+ if not orders:
235
+ return "<div style='color: #999;'>처방 μ—†μŒ</div>"
236
+
237
+ html = '<div style="line-height: 1.9;">'
238
+ for i, order in enumerate(orders, 1):
239
+ if any(keyword in order for keyword in ["IV", "injection", "μ •μ£Ό", "주사"]):
240
+ icon = "πŸ’‰"
241
+ elif any(keyword in order for keyword in ["PO", "oral", "경ꡬ"]):
242
+ icon = "πŸ’Š"
243
+ elif "NPO" in order or "κΈˆμ‹" in order:
244
+ icon = "🚫"
245
+ elif any(keyword in order for keyword in ["수술", "surgery", "operation"]):
246
+ icon = "πŸ”ͺ"
247
+ else:
248
+ icon = "πŸ“‹"
249
+
250
+ html += f'<div style="padding: 6px 0; border-bottom: 1px dotted #e0e0e0;">{icon} <strong>{i}.</strong> {order}</div>'
251
+ html += "</div>"
252
+ return html
253
+
254
+
255
+ def create_nursing_notes_timeline(notes):
256
+ """κ°„ν˜Έ 기둝 νƒ€μž„λΌμΈ 생성"""
257
+ if not notes:
258
+ return "<div style='color: #999;'>기둝 μ—†μŒ</div>"
259
+
260
+ html = '<div style="position: relative; padding-left: 20px;">'
261
+ for note in notes:
262
+ time = note.get("time", "N/A")
263
+ content = note.get("note", "")
264
+ html += f"""
265
+ <div style="position: relative; margin-bottom: 15px; padding: 12px; background: #f8f9fa; border-left: 3px solid #667eea; border-radius: 4px;">
266
+ <div style="font-size: 11px; color: #667eea; font-weight: bold; margin-bottom: 5px;">πŸ• {time}</div>
267
+ <div style="font-size: 13px; color: #333; line-height: 1.6;">{content}</div>
268
+ </div>
269
+ """
270
+ html += "</div>"
271
+ return html
272
+
273
+
274
+ def create_surgery_info_panel(scenario):
275
+ """수술 정보 νŒ¨λ„ 생성"""
276
+ if not scenario.surgery_info:
277
+ return ""
278
+
279
+ surgery = scenario.surgery_info
280
+ return f"""
281
+ <div style="background: #fff8e1; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); border-left: 4px solid #ffa726;">
282
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px;">
283
+ πŸ₯ 수술 정보
284
+ </div>
285
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 13px;">
286
+ <div><strong>μˆ μ‹:</strong> {surgery.get('procedure', 'N/A')}</div>
287
+ <div><strong>μˆ˜μˆ μ‹œκ°„:</strong> {surgery.get('time', 'N/A')}</div>
288
+ <div><strong>μΆœν˜ˆλŸ‰:</strong> {surgery.get('EBL', 'N/A')}</div>
289
+ <div><strong>μˆ˜μ•‘λŸ‰:</strong> {surgery.get('fluids', 'N/A')}</div>
290
+ <div style="grid-column: 1 / -1;"><strong>μ†Œκ²¬:</strong> {surgery.get('findings', 'N/A')}</div>
291
+ <div style="grid-column: 1 / -1;"><strong>합병증:</strong> {surgery.get('complications', 'μ—†μŒ')}</div>
292
+ </div>
293
+ </div>
294
+ """
295
+
296
+
297
+ def create_discharge_education_panel(scenario):
298
+ """퇴원 ꡐ윑 νŒ¨λ„ 생성"""
299
+ if not scenario.discharge_education:
300
+ return ""
301
+
302
+ html = """
303
+ <div style="background: #e8f5e9; padding: 15px; border-radius: 8px; margin-bottom: 15px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); border-left: 4px solid #4caf50;">
304
+ <div style="font-size: 14px; font-weight: bold; color: #333; margin-bottom: 12px;">
305
+ πŸ“š 퇴원 ꡐ윑
306
+ </div>
307
+ <div style="font-size: 13px; line-height: 1.8;">
308
+ """
309
+
310
+ for edu in scenario.discharge_education:
311
+ html += f'<div style="padding: 6px 0; border-bottom: 1px dotted #c8e6c9;">βœ“ {edu}</div>'
312
+
313
+ html += "</div></div>"
314
+ return html
315
+
316
+
317
+ # ===== 핡심 둜직 ν•¨μˆ˜λ“€ =====
318
+
319
+ def load_scenario_data(scenario_id: str):
320
+ """μ‹œλ‚˜λ¦¬μ˜€ 선택 μ‹œ EMR 데이터 λ‘œλ“œ"""
321
+ if not scenario_id:
322
+ return None, None, ""
323
+
324
+ db = SessionLocal()
325
+ db_service = DatabaseService(db)
326
+
327
+ scenario = db_service.get_scenario(scenario_id)
328
+ patient = db_service.get_patient(scenario.patient_id) if scenario else None
329
+
330
+ db.close()
331
+
332
+ if not scenario or not patient:
333
+ return None, None, "μ‹œλ‚˜λ¦¬μ˜€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."
334
+
335
+ emr_display = create_emr_html(patient, scenario)
336
+ return scenario, patient, emr_display
337
+
338
+
339
+ def handle_chat_message(message, chat_history, session_id, student_id, scenario_id):
340
+ """μ±„νŒ… λ©”μ‹œμ§€ 처리"""
341
+ if not message or not message.strip():
342
+ return chat_history, ""
343
+
344
+ # μ‹œλ‚˜λ¦¬μ˜€κ°€ μ„ νƒλ˜μ§€ μ•Šμ€ 경우
345
+ if not scenario_id or scenario_id == "":
346
+ warning_msg = """
347
+ ⚠️ **μ‹œλ‚˜λ¦¬μ˜€λ₯Ό λ¨Όμ € μ„ νƒν•΄μ£Όμ„Έμš”!**
348
+
349
+ 1. **EMR 정보 쑰회** νƒ­μœΌλ‘œ μ΄λ™ν•˜μ„Έμš”
350
+ 2. μ‹œλ‚˜λ¦¬μ˜€ λ“œλ‘­λ‹€μš΄μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜μ„Έμš”
351
+ 3. EMR 정보λ₯Ό ν™•μΈν•œ ν›„ λ‹€μ‹œ 이 νŽ˜μ΄μ§€λ‘œ λŒμ•„μ™€ μ±„νŒ…μ„ μ‹œμž‘ν•˜μ„Έμš”
352
+
353
+ ν˜„μž¬ μ„ νƒλœ μ‹œλ‚˜λ¦¬μ˜€: μ—†μŒ
354
+ """
355
+ return chat_history + [[message, warning_msg]], ""
356
+
357
+ # λ°μ΄ν„°λ² μ΄μŠ€ μ—°κ²°
358
+ db = SessionLocal()
359
+ db_service = DatabaseService(db)
360
+
361
+ # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 정보 쑰회
362
+ scenario = db_service.get_scenario(scenario_id)
363
+ patient = db_service.get_patient(scenario.patient_id) if scenario else None
364
+
365
+ if not scenario or not patient:
366
+ db.close()
367
+ error_msg = """
368
+ ❌ **μ‹œλ‚˜λ¦¬μ˜€ 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.**
369
+
370
+ EMR 정보 쑰회 νƒ­μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό λ‹€μ‹œ μ„ νƒν•΄μ£Όμ„Έμš”.
371
+ """
372
+ return chat_history + [[message, error_msg]], ""
373
+
374
+ # μ‚¬μš©μž λ©”μ‹œμ§€ μ €μž₯
375
+ db_service.save_chat_message(session_id, student_id, scenario_id, "user", message)
376
+
377
+ # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 데이터 μ€€λΉ„
378
+ scenario_data = {
379
+ "title": scenario.title,
380
+ "handoff_situation": scenario.handoff_situation,
381
+ "hospital_day": scenario.hospital_day,
382
+ "vitals": scenario.vitals,
383
+ "labs": scenario.labs,
384
+ "orders": scenario.orders,
385
+ "nursing_notes": scenario.nursing_notes,
386
+ }
387
+
388
+ patient_data = {
389
+ "name": patient.name,
390
+ "age": patient.age,
391
+ "gender": patient.gender,
392
+ "diagnosis": patient.diagnosis,
393
+ "admission_date": str(patient.admission_date),
394
+ "allergies": patient.allergies,
395
+ }
396
+
397
+ # 이전 μ±„νŒ… 기둝 ν¬λ§·νŒ… (Gemini용)
398
+ history_for_gemini = []
399
+ for user_msg, bot_msg in chat_history:
400
+ if user_msg:
401
+ history_for_gemini.append(("user", user_msg))
402
+ if bot_msg:
403
+ history_for_gemini.append(("assistant", bot_msg))
404
+
405
+ # Gemini AI ν”Όλ“œλ°± 생성
406
+ ai_response = evaluator.chat_feedback(message, history_for_gemini, scenario_data, patient_data)
407
+
408
+ # AI 응닡 μ €μž₯
409
+ db_service.save_chat_message(session_id, student_id, scenario_id, "assistant", ai_response)
410
+
411
+ db.close()
412
+
413
+ # μ±„νŒ… νžˆμŠ€ν† λ¦¬ μ—…λ°μ΄νŠΈ
414
+ updated_history = chat_history + [[message, ai_response]]
415
+
416
+ return updated_history, ""
417
+
418
+
419
+ def submit_final_handoff(chat_history, session_id, student_id, scenario_id):
420
+ """μ΅œμ’… 제좜 및 평가"""
421
+ if not chat_history:
422
+ return chat_history + [[None, "λ¨Όμ € SBAR μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•΄μ£Όμ„Έμš”."]], ""
423
+
424
+ # μ±„νŒ… κΈ°λ‘μ—μ„œ SBAR μΆ”μΆœ (ν•™μƒμ˜ λ§ˆμ§€λ§‰ λ©”μ‹œμ§€λ“€ 뢄석)
425
+ # κ°„λ‹¨νžˆ ν•˜κΈ° μœ„ν•΄ ν•™μƒμ—κ²Œ λͺ…μ‹œμ μœΌλ‘œ SBAR ν˜•μ‹μœΌλ‘œ μž‘μ„±ν•˜λ„λ‘ μš”μ²­
426
+ final_message = """
427
+ μ΅œμ’… μ œμΆœμ„ μš”μ²­ν•˜μ…¨μŠ΅λ‹ˆλ‹€.
428
+
429
+ μ§€κΈˆκΉŒμ§€ μž‘μ„±ν•˜μ‹  λ‚΄μš©μ„ λ°”νƒ•μœΌλ‘œ μ΅œμ’… 평가λ₯Ό μ§„ν–‰ν•˜κ² μŠ΅λ‹ˆλ‹€.
430
+ SBAR ν˜•μ‹μœΌλ‘œ μ •λ¦¬λœ λ‚΄μš©μ„ λ‹€μ‹œ ν•œ 번 μž‘μ„±ν•΄μ£Όμ„Έμš”:
431
+
432
+ **S - Situation (상황):**
433
+ [여기에 μž‘μ„±]
434
+
435
+ **B - Background (λ°°κ²½):**
436
+ [여기에 μž‘μ„±]
437
+
438
+ **A - Assessment (평가):**
439
+ [여기에 μž‘μ„±]
440
+
441
+ **R - Recommendation (κΆŒκ³ μ‚¬ν•­):**
442
+ [여기에 μž‘μ„±]
443
+
444
+ μœ„ ν˜•μ‹μœΌλ‘œ μž‘μ„± ν›„ λ‹€μ‹œ "μ΅œμ’… 제좜" λ²„νŠΌμ„ λˆŒλŸ¬μ£Όμ„Έμš”.
445
+ """
446
+
447
+ # μ‹€μ œ κ΅¬ν˜„μ—μ„œλŠ” μ±„νŒ… 기둝을 νŒŒμ‹±ν•˜μ—¬ SBAR μΆ”μΆœ
448
+ # μ—¬κΈ°μ„œλŠ” κ°„λ‹¨νžˆ μ•ˆλ‚΄ λ©”μ‹œμ§€λ§Œ λ°˜ν™˜
449
+ return chat_history + [[None, final_message]], ""
450
+
451
+
452
+ def extract_sbar_from_message(message):
453
+ """λ©”μ‹œμ§€μ—μ„œ SBAR μΆ”μΆœ"""
454
+ import re
455
+
456
+ sbar = {
457
+ "situation": "",
458
+ "background": "",
459
+ "assessment": "",
460
+ "recommendation": ""
461
+ }
462
+
463
+ # S, B, A, R μ„Ήμ…˜ μΆ”μΆœ
464
+ s_match = re.search(r'\*\*S.*?Situation.*?\*\*:?\s*(.*?)(?=\*\*B|\*\*Background|$)', message, re.DOTALL | re.IGNORECASE)
465
+ b_match = re.search(r'\*\*B.*?Background.*?\*\*:?\s*(.*?)(?=\*\*A|\*\*Assessment|$)', message, re.DOTALL | re.IGNORECASE)
466
+ a_match = re.search(r'\*\*A.*?Assessment.*?\*\*:?\s*(.*?)(?=\*\*R|\*\*Recommendation|$)', message, re.DOTALL | re.IGNORECASE)
467
+ r_match = re.search(r'\*\*R.*?Recommendation.*?\*\*:?\s*(.*?)$', message, re.DOTALL | re.IGNORECASE)
468
+
469
+ if s_match:
470
+ sbar["situation"] = s_match.group(1).strip()
471
+ if b_match:
472
+ sbar["background"] = b_match.group(1).strip()
473
+ if a_match:
474
+ sbar["assessment"] = a_match.group(1).strip()
475
+ if r_match:
476
+ sbar["recommendation"] = r_match.group(1).strip()
477
+
478
+ return sbar
479
+
480
+
481
+ def process_final_submission(message, chat_history, session_id, student_id, scenario_id):
482
+ """SBAR ν˜•μ‹ λ©”μ‹œμ§€λ₯Ό λ°›μ•„ μ΅œμ’… 평가 μˆ˜ν–‰"""
483
+ # SBAR μΆ”μΆœ
484
+ sbar_data = extract_sbar_from_message(message)
485
+
486
+ # λͺ¨λ“  μ„Ήμ…˜μ΄ μž‘μ„±λ˜μ—ˆλŠ”μ§€ 확인
487
+ if not all([sbar_data["situation"], sbar_data["background"],
488
+ sbar_data["assessment"], sbar_data["recommendation"]]):
489
+ return chat_history + [[message, "SBAR ν˜•μ‹μ΄ μ™„μ „ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. S, B, A, R λͺ¨λ“  μ„Ήμ…˜μ„ μž‘μ„±ν•΄μ£Όμ„Έμš”."]], ""
490
+
491
+ # λ°μ΄ν„°λ² μ΄μŠ€ μ—°κ²°
492
+ db = SessionLocal()
493
+ db_service = DatabaseService(db)
494
+
495
+ # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 정보 쑰회
496
+ scenario = db_service.get_scenario(scenario_id)
497
+ patient = db_service.get_patient(scenario.patient_id) if scenario else None
498
+
499
+ if not scenario or not patient:
500
+ db.close()
501
+ return chat_history + [[message, "μ‹œλ‚˜λ¦¬μ˜€ 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."]], ""
502
+
503
+ # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 데이터 μ€€λΉ„
504
+ scenario_data = {
505
+ "title": scenario.title,
506
+ "handoff_situation": scenario.handoff_situation,
507
+ "hospital_day": scenario.hospital_day,
508
+ "vitals": scenario.vitals,
509
+ "labs": scenario.labs,
510
+ "orders": scenario.orders,
511
+ "nursing_notes": scenario.nursing_notes,
512
+ }
513
+
514
+ patient_data = {
515
+ "name": patient.name,
516
+ "age": patient.age,
517
+ "gender": patient.gender,
518
+ "diagnosis": patient.diagnosis,
519
+ "admission_date": str(patient.admission_date),
520
+ "allergies": patient.allergies,
521
+ }
522
+
523
+ # Gemini AI 평가
524
+ evaluation = evaluator.evaluate_handoff(sbar_data, scenario_data, patient_data)
525
+
526
+ # λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯
527
+ try:
528
+ record = db_service.create_handoff_record(
529
+ student_id=student_id,
530
+ scenario_id=scenario_id,
531
+ sbar_data=sbar_data,
532
+ evaluation=evaluation,
533
+ session_id=session_id
534
+ )
535
+ print(f"βœ… μΈμˆ˜μΈκ³„ 기둝 μ €μž₯ μ™„λ£Œ (ID: {record.id})")
536
+ except Exception as e:
537
+ print(f"⚠️ λ°μ΄ν„°λ² μ΄μŠ€ μ €μž₯ 였λ₯˜: {e}")
538
+
539
+ db.close()
540
+
541
+ # 평가 κ²°κ³Ό ν¬λ§·νŒ…
542
+ total_score = evaluation.get("total_score", 0)
543
+ category_scores = evaluation.get("category_scores", {})
544
+ strengths = evaluation.get("strengths", [])
545
+ improvements = evaluation.get("improvements", [])
546
+ detailed_feedback = evaluation.get("detailed_feedback", "")
547
+
548
+ result_message = f"""
549
+ πŸŽ‰ **μ΅œμ’… 평가 μ™„λ£Œ!**
550
+
551
+ **총점: {total_score:.1f} / 100**
552
+
553
+ **μΉ΄ν…Œκ³ λ¦¬λ³„ 점수:**
554
+ - μ™„μ „μ„± (Completeness): {category_scores.get('completeness', 0)} / 25
555
+ - μ •ν™•μ„± (Accuracy): {category_scores.get('accuracy', 0)} / 25
556
+ - λͺ…λ£Œμ„± (Clarity): {category_scores.get('clarity', 0)} / 25
557
+ - μš°μ„ μˆœμœ„ (Priority): {category_scores.get('priority', 0)} / 25
558
+
559
+ **πŸ’ͺ μž˜ν•œ 점:**
560
+ {chr(10).join([f"- {s}" for s in strengths])}
561
+
562
+ **πŸ“ˆ κ°œμ„ ν•  점:**
563
+ {chr(10).join([f"- {i}" for i in improvements])}
564
+
565
+ **πŸ“ μ’…ν•© ν”Όλ“œλ°±:**
566
+ {detailed_feedback}
567
+
568
+ ---
569
+ μˆ˜κ³ ν•˜μ…¨μŠ΅λ‹ˆλ‹€! λ‹€λ₯Έ μ‹œλ‚˜λ¦¬μ˜€λ‘œ μ—°μŠ΅μ„ κ³„μ†ν•˜μ‹œλ €λ©΄ EMR νŽ˜μ΄μ§€λ‘œ λŒμ•„κ°€ μƒˆλ‘œμš΄ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜μ„Έμš”.
570
+ """
571
+
572
+ return chat_history + [[message, result_message]], ""
573
+
574
+
575
+ def get_student_history(student_id: str):
576
+ """학생 μΈμˆ˜μΈκ³„ 이λ ₯ 쑰회 (κ΄€λ¦¬μžμš©)"""
577
+ if not student_id:
578
+ return "학생 IDλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”."
579
+
580
+ db = SessionLocal()
581
+ db_service = DatabaseService(db)
582
+
583
+ stats = db_service.get_student_statistics(student_id)
584
+ records = db_service.get_records_by_student(student_id)
585
+
586
+ db.close()
587
+
588
+ if stats["total_submissions"] == 0:
589
+ return f"학생 ID '{student_id}'의 제좜 기둝이 μ—†μŠ΅λ‹ˆλ‹€."
590
+
591
+ history = f"""
592
+ # 학생 μΈμˆ˜μΈκ³„ 이λ ₯
593
+
594
+ **학생 ID**: {student_id}
595
+
596
+ ## 톡계
597
+ - **총 제좜 횟수**: {stats['total_submissions']}회
598
+ - **평균 점수**: {stats['average_score']:.1f}점
599
+ - **졜고 점수**: {stats['highest_score']:.1f}점
600
+ - **μ΅œμ € 점수**: {stats['lowest_score']:.1f}점
601
+
602
+ ## 졜근 제좜 기둝
603
+ """
604
+
605
+ for i, record in enumerate(records[:10], 1):
606
+ submitted_time = record.submitted_at.strftime("%Y-%m-%d %H:%M") if record.submitted_at else "N/A"
607
+ history += f"""
608
+ ### {i}. {record.scenario_id}
609
+ - **제좜 μ‹œκ°„**: {submitted_time}
610
+ - **점수**: {record.total_score:.1f}점
611
+ - **강점**: {', '.join(record.strengths[:2]) if record.strengths else 'N/A'}
612
+ ---
613
+ """
614
+
615
+ return history
616
+
617
+
618
+ # ===== Gradio UI ꡬ성 =====
619
+
620
+ with gr.Blocks(title="κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ", theme=gr.themes.Soft()) as app:
621
+
622
+ # μ „μ—­ μƒνƒœ λ³€μˆ˜
623
+ session_id_state = gr.State(value="")
624
+ student_id_state = gr.State(value="")
625
+ scenario_id_state = gr.State(value="S001_D0_ER_WARD") # κΈ°λ³Έκ°’ μ„€μ •
626
+ user_role_state = gr.State(value="student")
627
+
628
+ gr.Markdown("""
629
+ # πŸ₯ κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ (μ±„νŒ… 기반)
630
+
631
+ SBAR ν˜•μ‹μœΌλ‘œ ν™˜μž μΈμˆ˜μΈκ³„λ₯Ό μ—°μŠ΅ν•˜κ³  AI와 λŒ€ν™”ν•˜λ©° ν”Όλ“œλ°±μ„ λ°›μ•„λ³΄μ„Έμš”.
632
+ """)
633
+
634
+ # 초기 μ„€μ • μ„Ήμ…˜
635
+ with gr.Row():
636
+ with gr.Column():
637
+ student_id_input = gr.Textbox(
638
+ label="πŸ‘€ 학생 ID",
639
+ placeholder="예: student001 (κ΄€λ¦¬μžλŠ” 'admin' μž…λ ₯)",
640
+ value="student001"
641
+ )
642
+ start_btn = gr.Button("μ‹œμž‘ν•˜κΈ°", variant="primary")
643
+
644
+ # νƒ­ ꡬ성
645
+ with gr.Tabs() as tabs:
646
+ # νŽ˜μ΄μ§€ 1: EMR 정보 쑰회
647
+ with gr.Tab("πŸ“Š EMR 정보 쑰회", id=0) as tab_emr:
648
+ gr.Markdown("## ν™˜μž μ „μžμ˜λ¬΄κΈ°λ‘ (EMR)")
649
+
650
+ # μ‚¬μš© 방법과 μ‹œλ‚˜λ¦¬μ˜€ 선택을 ν•œ 행에 2μ—΄λ‘œ 배치
651
+ with gr.Row():
652
+ with gr.Column(scale=2):
653
+ gr.Markdown("""
654
+ **πŸ“Œ μ‚¬μš© 방법:**
655
+ 1. μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜μ„Έμš”
656
+ 2. EMR 정보λ₯Ό μΆ©λΆ„νžˆ ν™•μΈν•˜μ„Έμš”
657
+ 3. μ€€λΉ„κ°€ 되면 "λ‹΅μ•ˆ μž‘μ„±ν•˜λŸ¬ κ°€κΈ°" λ²„νŠΌμ„ ν΄λ¦­ν•˜μ„Έμš”
658
+ """)
659
+ with gr.Column(scale=3):
660
+ scenario_dropdown = gr.Dropdown(
661
+ choices=[
662
+ ("Day 0 - 응급싀→병동 인계 (수술 μ „)", "S001_D0_ER_WARD"),
663
+ ("Day 0 - λ³‘λ™β†’μˆ˜μˆ μ‹€ 인계", "S001_D0_WARD_OR"),
664
+ ("Day 0 - νšŒλ³΅μ‹€β†’λ³‘λ™ 전동 (수술 ν›„)", "S001_D0_RECOVERY"),
665
+ ("Day 1 - μ•„μΉ¨ 인계 (POD1)", "S001_D1_MORNING"),
666
+ ("Day 2 - 퇴원 μ „ 인계 (POD2)", "S001_D2_DISCHARGE"),
667
+ ],
668
+ label="πŸ“‹ μ‹œλ‚˜λ¦¬μ˜€ 선택",
669
+ value="S001_D0_ER_WARD",
670
+ info="μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜λ©΄ EMR 정보가 ν‘œμ‹œλ©λ‹ˆλ‹€"
671
+ )
672
+
673
+ emr_display = gr.HTML(
674
+ value="<div style='text-align: center; padding: 40px; color: #999;'>⬆️ μœ„μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜μ„Έμš”.</div>"
675
+ )
676
+
677
+ go_to_chat_btn = gr.Button("πŸ’¬ λ‹΅μ•ˆ μž‘μ„±ν•˜λŸ¬ κ°€κΈ°", variant="primary", size="lg")
678
+ gr.Markdown("""
679
+ > πŸ’‘ **Tip**: EMR νŽ˜μ΄μ§€μ—μ„œ μ–Έμ œλ“ μ§€ "λ‹΅μ•ˆ μž‘μ„±" νƒ­μœΌλ‘œ 이동할 수 μžˆμŠ΅λ‹ˆλ‹€.
680
+ """)
681
+
682
+ # νŽ˜μ΄μ§€ 2: μ±„νŒ… 기반 λ‹΅μ•ˆ μž‘μ„±
683
+ with gr.Tab("πŸ’¬ λ‹΅μ•ˆ μž‘μ„± (μ±„νŒ…)", id=1) as tab_chat:
684
+ gr.Markdown("## SBAR μΈμˆ˜μΈκ³„ μž‘μ„± 및 ν”Όλ“œλ°±")
685
+ gr.Markdown("""
686
+ **πŸ“Œ μ‹œμž‘ν•˜κΈ°:**
687
+ 1. κΈ°λ³Έ μ‹œλ‚˜λ¦¬μ˜€λŠ” **"Day 0 - 응급싀→병동 인계"**μž…λ‹ˆλ‹€ (μƒλ‹¨μ—μ„œ 확인 κ°€λŠ₯)
688
+ 2. EMR 정보λ₯Ό ν™•μΈν•˜λ €λ©΄ **"EMR 정보 쑰회"** 탭을 ν΄λ¦­ν•˜μ„Έμš”
689
+ 3. μ€€λΉ„κ°€ 되면 μ•„λž˜ λ©”μ‹œμ§€ μž…λ ₯창에 SBAR λ‚΄μš©μ„ μž‘μ„±ν•˜μ„Έμš”
690
+ """)
691
+
692
+ with gr.Row():
693
+ current_scenario_display = gr.Markdown("**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: λ‘œλ”© 쀑...")
694
+ back_to_emr_btn = gr.Button("πŸ“Š EMR λ‹€μ‹œ 보기", size="sm", variant="secondary")
695
+
696
+ chatbot = gr.Chatbot(
697
+ label="AI κ΅μˆ˜μ™€ λŒ€ν™”",
698
+ height=500,
699
+ show_label=True
700
+ )
701
+
702
+ with gr.Row():
703
+ msg_input = gr.Textbox(
704
+ label="λ©”μ‹œμ§€ μž…λ ₯",
705
+ placeholder="SBAR ν˜•μ‹μœΌλ‘œ μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜κ±°λ‚˜, μ§ˆλ¬Έμ„ μž…λ ₯ν•˜μ„Έμš”...",
706
+ lines=3,
707
+ scale=4
708
+ )
709
+
710
+ with gr.Row():
711
+ send_btn = gr.Button("전솑", variant="primary")
712
+ submit_final_btn = gr.Button("μ΅œμ’… μ œμΆœν•˜κΈ°", variant="stop")
713
+ clear_btn = gr.Button("λŒ€ν™” μ΄ˆκΈ°ν™”")
714
+
715
+ gr.Markdown("""
716
+ **μ‚¬μš© 방법:**
717
+ 1. SBAR ν˜•μ‹μœΌλ‘œ μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜μ„Έμš”
718
+ 2. AI κ΅μˆ˜κ°€ ν”Όλ“œλ°±μ„ μ œκ³΅ν•©λ‹ˆλ‹€
719
+ 3. ν”Όλ“œλ°±μ„ λ°”νƒ•μœΌλ‘œ μˆ˜μ •ν•˜κ³  λ‹€μ‹œ μž‘μ„±ν•  수 μžˆμŠ΅λ‹ˆλ‹€
720
+ 4. μ€€λΉ„κ°€ 되면 **μ΅œμ’… μ œμΆœν•˜κΈ°**λ₯Ό 눌러 평가λ₯Ό λ°›μœΌμ„Έμš”
721
+
722
+ **μ΅œμ’… 제좜 μ‹œ ν˜•μ‹:**
723
+ ```
724
+ **S - Situation (상황):**
725
+ [λ‚΄μš©]
726
+
727
+ **B - Background (λ°°κ²½):**
728
+ [λ‚΄μš©]
729
+
730
+ **A - Assessment (평가):**
731
+ [λ‚΄μš©]
732
+
733
+ **R - Recommendation (κΆŒκ³ μ‚¬ν•­):**
734
+ [λ‚΄μš©]
735
+ ```
736
+ """)
737
+
738
+ # νŽ˜μ΄μ§€ 3: κ΄€λ¦¬μž 이λ ₯ 쑰회
739
+ with gr.Tab("πŸ“š 학생 이λ ₯ 쑰회 (κ΄€λ¦¬μž)", id=2, visible=False) as tab_admin:
740
+ gr.Markdown("## 학생 μΈμˆ˜μΈκ³„ 이λ ₯ 쑰회")
741
+
742
+ with gr.Row():
743
+ history_student_id = gr.Textbox(label="학생 ID", placeholder="student001")
744
+ history_btn = gr.Button("이λ ₯ 쑰회")
745
+
746
+ history_display = gr.Markdown(value="")
747
+
748
+ # ===== 이벀트 ν•Έλ“€λŸ¬ =====
749
+
750
+ def initialize_session(student_id):
751
+ """μ„Έμ…˜ μ΄ˆκΈ°ν™”"""
752
+ is_admin = (student_id == "admin")
753
+ role = "admin" if is_admin else "student"
754
+
755
+ # κ΄€λ¦¬μžμΈ 경우 κ΄€λ¦¬μž νƒ­ ν‘œμ‹œ
756
+ tab_visibility = gr.update(visible=is_admin)
757
+
758
+ return student_id, role, tab_visibility, gr.update(value=f"ν™˜μ˜ν•©λ‹ˆλ‹€, {student_id}λ‹˜!")
759
+
760
+ start_btn.click(
761
+ fn=initialize_session,
762
+ inputs=[student_id_input],
763
+ outputs=[student_id_state, user_role_state, tab_admin, gr.Markdown()]
764
+ )
765
+
766
+ def select_scenario_and_create_session(scenario_id, student_id):
767
+ """μ‹œλ‚˜λ¦¬μ˜€ 선택 및 μ„Έμ…˜ 생성"""
768
+ scenario, patient, emr_html = load_scenario_data(scenario_id)
769
+
770
+ if scenario:
771
+ # μƒˆ μ±„νŒ… μ„Έμ…˜ 생성
772
+ db = SessionLocal()
773
+ db_service = DatabaseService(db)
774
+ new_session_id = db_service.create_chat_session(student_id, scenario_id)
775
+ db.close()
776
+
777
+ scenario_info = f"**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: {scenario.title}"
778
+
779
+ return emr_html, new_session_id, scenario_id, scenario_info
780
+
781
+ return emr_html, "", scenario_id, "**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: μ„ νƒλ˜μ§€ μ•ŠμŒ"
782
+
783
+ scenario_dropdown.change(
784
+ fn=select_scenario_and_create_session,
785
+ inputs=[scenario_dropdown, student_id_state],
786
+ outputs=[emr_display, session_id_state, scenario_id_state, current_scenario_display]
787
+ )
788
+
789
+ # μ±„νŒ… λ©”μ‹œμ§€ 전솑 (κ°œμ„ λœ 버전)
790
+ send_btn.click(
791
+ fn=handle_chat_message,
792
+ inputs=[msg_input, chatbot, session_id_state, student_id_state, scenario_id_state],
793
+ outputs=[chatbot, msg_input],
794
+ show_progress="minimal"
795
+ )
796
+
797
+ msg_input.submit(
798
+ fn=handle_chat_message,
799
+ inputs=[msg_input, chatbot, session_id_state, student_id_state, scenario_id_state],
800
+ outputs=[chatbot, msg_input],
801
+ show_progress="minimal"
802
+ )
803
+
804
+ # μ΅œμ’… 제좜
805
+ submit_final_btn.click(
806
+ fn=process_final_submission,
807
+ inputs=[msg_input, chatbot, session_id_state, student_id_state, scenario_id_state],
808
+ outputs=[chatbot, msg_input]
809
+ )
810
+
811
+ # λŒ€ν™” μ΄ˆκΈ°ν™”
812
+ def clear_chat(student_id, scenario_id):
813
+ """λŒ€ν™” μ΄ˆκΈ°ν™” 및 μƒˆ μ„Έμ…˜ 생성"""
814
+ db = SessionLocal()
815
+ db_service = DatabaseService(db)
816
+ new_session_id = db_service.create_chat_session(student_id, scenario_id)
817
+ db.close()
818
+
819
+ return [], new_session_id
820
+
821
+ clear_btn.click(
822
+ fn=clear_chat,
823
+ inputs=[student_id_state, scenario_id_state],
824
+ outputs=[chatbot, session_id_state]
825
+ )
826
+
827
+ # 이λ ₯ 쑰회
828
+ history_btn.click(
829
+ fn=get_student_history,
830
+ inputs=[history_student_id],
831
+ outputs=[history_display]
832
+ )
833
+
834
+ # λ²„νŠΌ ν•Έλ“€λŸ¬ μΆ”κ°€
835
+ def navigate_to_chat(session_id, scenario_id):
836
+ """μ±„νŒ… νŽ˜μ΄μ§€λ‘œ 이동"""
837
+ return gr.update(selected=1) # μ±„νŒ… νƒ­μœΌλ‘œ μ „ν™˜
838
+
839
+ def navigate_to_emr():
840
+ """EMR νŽ˜μ΄μ§€λ‘œ 이동"""
841
+ return gr.update(selected=0) # EMR νƒ­μœΌλ‘œ μ „ν™˜
842
+
843
+ # λ‹΅μ•ˆ μž‘μ„±ν•˜κΈ° λ²„νŠΌ 클릭 μ‹œ μ±„νŒ… νŽ˜μ΄μ§€λ‘œ 이동
844
+ go_to_chat_btn.click(
845
+ fn=navigate_to_chat,
846
+ inputs=[session_id_state, scenario_id_state],
847
+ outputs=[tabs]
848
+ )
849
+
850
+ # EMR λ‹€μ‹œ 보기 λ²„νŠΌ 클릭 μ‹œ EMR νŽ˜μ΄μ§€λ‘œ 이동
851
+ back_to_emr_btn.click(
852
+ fn=navigate_to_emr,
853
+ outputs=[tabs]
854
+ )
855
+
856
+ # 초기 λ‘œλ“œ ν•¨μˆ˜
857
+ def initialize_app():
858
+ """μ•± 초기 λ‘œλ“œ μ‹œ μ‹€ν–‰"""
859
+ default_scenario = "S001_D0_ER_WARD"
860
+ scenario, patient, emr_html = load_scenario_data(default_scenario)
861
+
862
+ if scenario:
863
+ # κΈ°λ³Έ μ‹œλ‚˜λ¦¬μ˜€ 정보 ν‘œμ‹œ
864
+ scenario_info = f"**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: {scenario.title}"
865
+ return emr_html, default_scenario, scenario_info
866
+ else:
867
+ return emr_html, default_scenario, "**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: μ„ νƒλ˜μ§€ μ•ŠμŒ"
868
+
869
+ # 초기 λ‘œλ“œ
870
+ app.load(
871
+ fn=initialize_app,
872
+ outputs=[emr_display, scenario_id_state, current_scenario_display]
873
+ )
874
+
875
+
876
+ if __name__ == "__main__":
877
+ app.launch(server_name="0.0.0.0", server_port=7860, share=False)
878
+
docker-compose.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ postgres:
5
+ image: postgres:15-alpine
6
+ container_name: nursing_handoff_db
7
+ environment:
8
+ POSTGRES_DB: nursing_handoff
9
+ POSTGRES_USER: postgres
10
+ POSTGRES_PASSWORD: postgres123
11
+ ports:
12
+ - "5433:5432"
13
+ volumes:
14
+ - postgres_data:/var/lib/postgresql/data
15
+ healthcheck:
16
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
17
+ interval: 10s
18
+ timeout: 5s
19
+ retries: 5
20
+
21
+ volumes:
22
+ postgres_data:
23
+
init_db.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 및 μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ 슀크립트
3
+ """
4
+
5
+ from config.database import SessionLocal, init_db
6
+ from services.db_service import load_scenarios_to_db
7
+
8
+ if __name__ == "__main__":
9
+ print("πŸš€ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” μ‹œμž‘...")
10
+
11
+ # ν…Œμ΄λΈ” 생성
12
+ init_db()
13
+
14
+ # μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ
15
+ print("\nπŸ“₯ μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ 쀑...")
16
+ db = SessionLocal()
17
+ try:
18
+ load_scenarios_to_db(db, "data/scenarios.json")
19
+ print("\nβœ… λͺ¨λ“  μ΄ˆκΈ°ν™” μž‘μ—…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!")
20
+ except Exception as e:
21
+ print(f"\n❌ 였λ₯˜ λ°œμƒ: {e}")
22
+ finally:
23
+ db.close()
pyproject.toml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "nursingtakeoveredu"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "google-generativeai>=0.8.5",
8
+ "gradio>=5.49.1",
9
+ "psycopg2-binary>=2.9.11",
10
+ "python-dotenv>=1.1.1",
11
+ "sqlalchemy>=2.0.44",
12
+ ]
start.sh ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "πŸ₯ κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ μ‹œμž‘..."
4
+ echo ""
5
+
6
+ # 1. PostgreSQL μ‹œμž‘
7
+ echo "πŸ“¦ PostgreSQL μ»¨ν…Œμ΄λ„ˆ μ‹œμž‘ 쀑..."
8
+ docker-compose up -d
9
+
10
+ # 2. PostgreSQL μ€€λΉ„ λŒ€κΈ°
11
+ echo "⏳ PostgreSQL μ€€λΉ„ λŒ€κΈ° 쀑..."
12
+ sleep 5
13
+
14
+ # 3. λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™”
15
+ echo "πŸ—„οΈ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 쀑..."
16
+ uv run init_db.py
17
+
18
+ # 4. Gradio μ•± μ‹€ν–‰
19
+ echo ""
20
+ echo "πŸš€ Gradio μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ‹œμž‘..."
21
+ echo "λΈŒλΌμš°μ €μ—μ„œ http://localhost:7860 으둜 μ ‘μ†ν•˜μ„Έμš”."
22
+ echo ""
23
+ uv run app.py
24
+