LearningnRunning commited on
Commit
3a338e5
Β·
1 Parent(s): 0136146

feat: Integrate Supabase for data management, replacing local PostgreSQL. Implement Supabase service for CRUD operations on patients and scenarios, and update application logic to utilize Supabase API. Add Dockerfile for containerization and .dockerignore for build optimization.

Browse files
.dockerignore ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git
2
+ .git
3
+ .gitignore
4
+ .gitattributes
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.py[cod]
9
+ *$py.class
10
+ *.so
11
+ .Python
12
+ *.egg-info/
13
+ dist/
14
+ build/
15
+
16
+ # Virtual environments
17
+ .venv/
18
+ venv/
19
+ ENV/
20
+ env/
21
+
22
+ # IDE
23
+ .vscode/
24
+ .idea/
25
+ *.swp
26
+ *.swo
27
+ .cursor/
28
+
29
+ # Documentation
30
+ *.md
31
+ !README.md
32
+
33
+ # Test files
34
+ test_*.py
35
+ *_test.py
36
+ tests/
37
+ examples/
38
+
39
+ # Build files
40
+ *.lock
41
+ .pytest_cache/
42
+ .coverage
43
+ htmlcov/
44
+
45
+ # OS
46
+ .DS_Store
47
+ Thumbs.db
48
+
49
+ # Database
50
+ *.db
51
+ *.sqlite3
52
+
53
+ # Logs
54
+ *.log
55
+
56
+ # Docker
57
+ Dockerfile.*
58
+ docker-compose*.yml
59
+ .dockerignore
60
+
61
+ # Misc
62
+ .svn
63
+ CVS
64
+ *.orig
65
+ *.rej
66
+
Dockerfile ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python 3.12 기반 이미지
2
+ FROM python:3.12-slim
3
+
4
+ # μž‘μ—… 디렉토리 μ„€μ •
5
+ WORKDIR /app
6
+
7
+ # μ‹œμŠ€ν…œ μ˜μ‘΄μ„± μ„€μΉ˜
8
+ RUN apt-get update && apt-get install -y \
9
+ gcc \
10
+ postgresql-client \
11
+ curl \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # ν™˜κ²½ λ³€μˆ˜ μ„€μ •
15
+ ENV PYTHONUNBUFFERED=1 \
16
+ PYTHONDONTWRITEBYTECODE=1
17
+
18
+ # uv μ„€μΉ˜ (λΉ λ₯Έ Python νŒ¨ν‚€μ§€ κ΄€λ¦¬μž)
19
+ RUN pip install --no-cache-dir uv
20
+
21
+ # μ˜μ‘΄μ„± 파일 볡사 및 μ„€μΉ˜
22
+ COPY pyproject.toml ./
23
+ RUN uv pip install --system -e .
24
+
25
+ # μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ½”λ“œ 볡사
26
+ COPY config/ ./config/
27
+ COPY models/ ./models/
28
+ COPY services/ ./services/
29
+ COPY data/ ./data/
30
+ COPY app.py init_db.py ./
31
+
32
+ # 포트 λ…ΈμΆœ (Gradio κΈ°λ³Έ 포트)
33
+ EXPOSE 7860
34
+
35
+ # ν—¬μŠ€μ²΄ν¬ μΆ”κ°€
36
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
37
+ CMD curl -f http://localhost:7860/ || exit 1
38
+
39
+ # μ‹œμž‘ 슀크립트
40
+ CMD ["sh", "-c", "uv run python init_db.py && uv run python app.py"]
41
+
README.md CHANGED
@@ -1,2 +1,173 @@
1
- # nurse-handover-simulator
 
2
  Clinical Handover Training Chatbot utilizing SBAR/ISBAR, EMR data, and immersive technology (Metaverse/VR) for standardized, high-fidelity nursing handover practice.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸ₯ κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ
2
+
3
  Clinical Handover Training Chatbot utilizing SBAR/ISBAR, EMR data, and immersive technology (Metaverse/VR) for standardized, high-fidelity nursing handover practice.
4
+
5
+ ## πŸ“‹ κ°œμš”
6
+
7
+ κ°„ν˜Έμ‚¬ SBAR ν˜•μ‹ μΈμˆ˜μΈκ³„ ν›ˆλ ¨μ„ μœ„ν•œ AI 기반 ꡐ윑 ν”Œλž«νΌμž…λ‹ˆλ‹€. Gradio 기반 μ›Ή μΈν„°νŽ˜μ΄μŠ€λ₯Ό 톡해 μ‹€μ œ EMR 데이터λ₯Ό ν™œμš©ν•œ μΈμˆ˜μΈκ³„ μ—°μŠ΅μ„ μ œκ³΅ν•©λ‹ˆλ‹€.
8
+
9
+ ## πŸš€ μ‹œμž‘ν•˜κΈ°
10
+
11
+ ### 1. μ €μž₯μ†Œ 클둠
12
+
13
+ ```bash
14
+ git clone https://github.com/your-username/nurse-handover-simulator.git
15
+ cd nurse-handover-simulator
16
+ ```
17
+
18
+ ### 2. ν™˜κ²½ λ³€μˆ˜ μ„€μ •
19
+
20
+ #### Supabase μ‚¬μš© (ꢌμž₯)
21
+
22
+ 1. `ENV_EXAMPLE` νŒŒμΌμ„ `.env`둜 λ³΅μ‚¬ν•©λ‹ˆλ‹€:
23
+ ```bash
24
+ cp ENV_EXAMPLE .env
25
+ ```
26
+
27
+ 2. Supabase ν”„λ‘œμ νŠΈ μ„€μ •μ—μ„œ ν•„μš”ν•œ 정보λ₯Ό λ³΅μ‚¬ν•©λ‹ˆλ‹€:
28
+ - Project URL: `https://project_id.supabase.co`
29
+ - API Key (anon key)
30
+ - Database URL (Connection String)
31
+
32
+ 3. `.env` νŒŒμΌμ— Supabase 정보λ₯Ό μž…λ ₯ν•©λ‹ˆλ‹€:
33
+ ```env
34
+ SUPABASE_URL=https://[project_id].supabase.co
35
+ SUPABASE_ANON_KEY=your_anon_key_here
36
+ SUPABASE_DB_URL=postgresql://postgres:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
37
+ GOOGLE_API_KEY=your_google_api_key_here
38
+ ```
39
+
40
+ #### 둜컬 PostgreSQL μ‚¬μš© (개발 ν™˜κ²½)
41
+
42
+ ν™˜κ²½ λ³€μˆ˜μ— `SUPABASE_DB_URL`을 μ„€μ •ν•˜μ§€ μ•ŠμœΌλ©΄ μžλ™μœΌλ‘œ 둜컬 PostgreSQL을 μ‚¬μš©ν•©λ‹ˆλ‹€.
43
+
44
+ ### 3. μ˜μ‘΄μ„± μ„€μΉ˜
45
+
46
+ ```bash
47
+ # uvλ₯Ό μ‚¬μš©ν•˜λŠ” 경우
48
+ uv sync
49
+
50
+ # λ˜λŠ” 일반 pip μ‚¬μš©
51
+ pip install -e .
52
+ ```
53
+
54
+ ### 4. Google Gemini API ν‚€ λ°œκΈ‰
55
+
56
+ 1. [Google AI Studio](https://makersuite.google.com/app/apikey)μ—μ„œ API ν‚€ λ°œκΈ‰
57
+ 2. `.env` νŒŒμΌμ— `GOOGLE_API_KEY` μΆ”κ°€
58
+
59
+ ### 5. μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ‹€ν–‰
60
+
61
+ ```bash
62
+ # start.sh μ‚¬μš© (ꢌμž₯)
63
+ bash start.sh
64
+
65
+ # λ˜λŠ” 직접 μ‹€ν–‰
66
+ uv run app.py
67
+ ```
68
+
69
+ ## 🐳 Docker 배포
70
+
71
+ ### 둜컬 Docker μ‹€ν–‰
72
+
73
+ ```bash
74
+ # Docker 이미지 λΉŒλ“œ
75
+ docker build -t nurse-handover-simulator .
76
+
77
+ # Docker μ»¨ν…Œμ΄λ„ˆ μ‹€ν–‰ (ν™˜κ²½ λ³€μˆ˜ 포함)
78
+ docker run -p 7860:7860 \
79
+ -e SUPABASE_URL="https://your-project.supabase.co" \
80
+ -e SUPABASE_ANON_KEY="your_key" \
81
+ -e SUPABASE_DB_URL="postgresql://..." \
82
+ -e GOOGLE_API_KEY="your_key" \
83
+ nurse-handover-simulator
84
+
85
+ # λ˜λŠ” .env 파일 μ‚¬μš©
86
+ docker run -p 7860:7860 --env-file .env nurse-handover-simulator
87
+ ```
88
+
89
+ ### Hugging Face Spaces 배포
90
+
91
+ 1. Hugging Face Spaces에 μƒˆ Space 생성 (Docker νƒ€μž… 선택)
92
+ 2. Repository Secrets에 λ‹€μŒ ν™˜κ²½ λ³€μˆ˜ 등둝:
93
+ - `SUPABASE_URL`
94
+ - `SUPABASE_ANON_KEY`
95
+ - `SUPABASE_DB_URL`
96
+ - `GOOGLE_API_KEY`
97
+ 3. Dockerfileκ³Ό μ½”λ“œ ν‘Έμ‹œ
98
+ 4. μžλ™ λΉŒλ“œ 및 배포 μ™„λ£Œ
99
+
100
+ ## πŸ“ ν”„λ‘œμ νŠΈ ꡬ쑰
101
+
102
+ ```
103
+ nurse-handover-simulator/
104
+ β”œβ”€β”€ app.py # Gradio 메인 μ• ν”Œλ¦¬μΌ€μ΄μ…˜
105
+ β”œβ”€β”€ config/
106
+ β”‚ └── database.py # λ°μ΄ν„°λ² μ΄μŠ€ μ—°κ²° μ„€μ •
107
+ β”œβ”€β”€ models/
108
+ β”‚ β”œβ”€β”€ patient_data.py # ν™˜μž 및 μ‹œλ‚˜λ¦¬μ˜€ λͺ¨λΈ
109
+ β”‚ β”œβ”€β”€ handoff_record.py # μΈμˆ˜μΈκ³„ 기둝 λͺ¨λΈ
110
+ β”‚ └── chat_history.py # μ±„νŒ… νžˆμŠ€ν† λ¦¬ λͺ¨λΈ
111
+ β”œβ”€β”€ services/
112
+ β”‚ β”œβ”€β”€ db_service.py # λ°μ΄ν„°λ² μ΄μŠ€ CRUD μ„œλΉ„μŠ€
113
+ β”‚ └── gemini_service.py # Google Gemini AI μ„œλΉ„μŠ€
114
+ β”œβ”€β”€ data/
115
+ β”‚ └── scenarios.json # μ‹œλ‚˜λ¦¬μ˜€ 데이터
116
+ β”œβ”€β”€ Dockerfile # Docker 배포 μ„€μ •
117
+ β”œβ”€β”€ docker-compose.yml # 둜컬 PostgreSQL μ„€μ •
118
+ β”œβ”€β”€ ENV_EXAMPLE # ν™˜κ²½ λ³€μˆ˜ ν…œν”Œλ¦Ώ
119
+ β”œβ”€β”€ start.sh # μ‹œμž‘ 슀크립트
120
+ └── init_db.py # λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 슀크립트
121
+ ```
122
+
123
+ ## πŸ”‘ ν™˜κ²½ λ³€μˆ˜ μ„€λͺ…
124
+
125
+ | λ³€μˆ˜ | μ„€λͺ… | ν•„μˆ˜ μ—¬λΆ€ |
126
+ |------|------|---------|
127
+ | `SUPABASE_URL` | Supabase ν”„λ‘œμ νŠΈ URL | Supabase μ‚¬μš© μ‹œ |
128
+ | `SUPABASE_ANON_KEY` | Supabase Anon API Key | Supabase μ‚¬μš© μ‹œ |
129
+ | `SUPABASE_DB_URL` | Supabase λ°μ΄ν„°λ² μ΄μŠ€ μ—°κ²° URL | Supabase μ‚¬μš© μ‹œ |
130
+ | `GOOGLE_API_KEY` | Google Gemini API ν‚€ | ν•„μˆ˜ |
131
+ | `DB_HOST` | 둜컬 PostgreSQL 호슀트 | 둜컬 PostgreSQL μ‚¬μš© μ‹œ |
132
+ | `DB_PORT` | 둜컬 PostgreSQL 포트 | 둜컬 PostgreSQL μ‚¬μš© μ‹œ |
133
+
134
+ ## πŸ”’ λ³΄μ•ˆ κ°€μ΄λ“œ
135
+
136
+ ### ν™˜κ²½ λ³€μˆ˜ 관리
137
+
138
+ 1. **μ ˆλŒ€ `.env` νŒŒμΌμ„ Git에 μ»€λ°‹ν•˜μ§€ λ§ˆμ„Έμš”**
139
+ - `.gitignore`에 `.env`κ°€ 이미 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€
140
+
141
+ 2. **Supabase Keys**
142
+ - `SUPABASE_ANON_KEY`: ν΄λΌμ΄μ–ΈνŠΈ λ…ΈμΆœ κ°€λŠ₯ (RLS둜 보호됨)
143
+ - `SUPABASE_DB_URL`: μ„œλ²„μ—μ„œλ§Œ μ‚¬μš©, λ…ΈμΆœ κΈˆμ§€
144
+
145
+ 3. **Google API Key**
146
+ - μ„œλ²„μ—μ„œλ§Œ μ‚¬μš©, λ…ΈμΆœ κΈˆμ§€
147
+
148
+ 4. **Hugging Face Spaces**
149
+ - Repository Secretsλ₯Ό μ‚¬μš©ν•˜μ—¬ ν™˜κ²½ λ³€μˆ˜λ₯Ό μ•ˆμ „ν•˜κ²Œ 관리
150
+
151
+ ## πŸ“Š μ£Όμš” κΈ°λŠ₯
152
+
153
+ - πŸ“‹ **EMR 정보 쑰회**: μ‹€μ œ 병원 EMRκ³Ό μœ μ‚¬ν•œ μΈν„°νŽ˜μ΄μŠ€
154
+ - πŸ’¬ **AI ν”Όλ“œλ°±**: Google Gemini AIλ₯Ό ν™œμš©ν•œ μ‹€μ‹œκ°„ ν”Όλ“œλ°±
155
+ - πŸ“ **SBAR ν˜•μ‹ μΈμˆ˜μΈκ³„**: ν‘œμ€€ μΈμˆ˜μΈκ³„ ν˜•μ‹ μ—°μŠ΅
156
+ - πŸ“Š **평가 μ‹œμŠ€ν…œ**: μ™„μ „μ„±, μ •ν™•μ„±, λͺ…λ£Œμ„±, μš°μ„ μˆœμœ„ 평가
157
+ - πŸ“š **학생 이λ ₯ 쑰회**: 학생별 ν•™μŠ΅ 이λ ₯ 및 톡계
158
+
159
+ ## πŸ› οΈ 기술 μŠ€νƒ
160
+
161
+ - **Backend**: Python 3.12
162
+ - **UI**: Gradio
163
+ - **Database**: Supabase (PostgreSQL) / 둜컬 PostgreSQL
164
+ - **AI**: Google Gemini 2.0 Flash
165
+ - **ORM**: SQLAlchemy
166
+
167
+ ## πŸ“ λΌμ΄μ„ μŠ€
168
+
169
+ MIT License
170
+
171
+ ## 🀝 κΈ°μ—¬ν•˜κΈ°
172
+
173
+ Pull RequestλŠ” μ–Έμ œλ“  ν™˜μ˜ν•©λ‹ˆλ‹€!
app.py CHANGED
@@ -2,24 +2,41 @@
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()
@@ -321,19 +338,45 @@ def load_scenario_data(scenario_id: str):
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):
@@ -354,16 +397,14 @@ def handle_chat_message(message, chat_history, session_id, student_id, scenario_
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
 
@@ -372,26 +413,26 @@ EMR 정보 쑰회 νƒ­μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό λ‹€μ‹œ μ„ νƒν•΄μ£Όμ„Έμš”.
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용)
@@ -406,9 +447,7 @@ EMR 정보 쑰회 νƒ­μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό λ‹€μ‹œ μ„ νƒν•΄μ£Όμ„Έμš”.
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]]
@@ -416,160 +455,113 @@ EMR 정보 쑰회 νƒ­μ—μ„œ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό λ‹€μ‹œ μ„ νƒν•΄μ£Όμ„Έμš”.
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):
@@ -577,13 +569,10 @@ def get_student_history(student_id: str):
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}'의 제좜 기둝이 μ—†μŠ΅λ‹ˆλ‹€."
@@ -603,12 +592,20 @@ def get_student_history(student_id: str):
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
 
@@ -681,12 +678,12 @@ with gr.Blocks(title="κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ", theme=gr.themes.Sof
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():
@@ -702,37 +699,27 @@ with gr.Blocks(title="κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ", theme=gr.themes.Sof
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: κ΄€λ¦¬μž 이λ ₯ 쑰회
@@ -769,10 +756,8 @@ with gr.Blocks(title="κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ", theme=gr.themes.Sof
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
 
@@ -801,20 +786,18 @@ with gr.Blocks(title="κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ", theme=gr.themes.Sof
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
 
 
2
  κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ - Gradio λ©€ν‹°νŽ˜μ΄μ§€ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ (μ±„νŒ… 기반)
3
  """
4
 
5
+ import os
6
+
7
  import gradio as gr
 
 
8
  from services.gemini_service import GeminiEvaluator
9
+ from services.supabase_service import SupabaseService
10
+
11
+ from config.database import supabase_client
12
 
13
  # λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™”
14
  try:
15
+ print("πŸ”— Supabase λͺ¨λ“œλ‘œ μ‹€ν–‰ν•©λ‹ˆλ‹€...")
16
+
17
+ # Supabase μ„œλΉ„μŠ€ μ΄ˆκΈ°ν™”
18
+ if supabase_client:
19
+ supabase_service = SupabaseService()
20
+ print("βœ… Supabase ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” μ™„λ£Œ")
21
+
22
+ # μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ (ν…Œμ΄λΈ”μ΄ μ—†λŠ” 경우 λ¬΄μ‹œ)
23
+ try:
24
+ print("πŸ“₯ μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ 쀑...")
25
+ supabase_service.load_scenarios_from_json("data/scenarios.json")
26
+ print("βœ… μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ μ™„λ£Œ")
27
+ except Exception as load_error:
28
+ print(f"⚠️ μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ μ‹€νŒ¨: {load_error}")
29
+ print(" Supabase Dashboardμ—μ„œ ν…Œμ΄λΈ”μ„ λ¨Όμ € μƒμ„±ν•΄μ£Όμ„Έμš”:")
30
+ print(" 1. supabase_schema.sql μ‹€ν–‰")
31
+ print(" 2. supabase_rls_fix_complete.sql μ‹€ν–‰")
32
+ else:
33
+ print("⚠️ Supabase ν΄λΌμ΄μ–ΈνŠΈκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
34
+ print(" ν™˜κ²½ λ³€μˆ˜ SUPABASE_URLκ³Ό SUPABASE_ANON_KEYλ₯Ό ν™•μΈν•˜μ„Έμš”.")
35
  except Exception as e:
36
  print(f"⚠️ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 쀑 였λ₯˜: {e}")
37
+ import traceback
38
+ traceback.print_exc()
39
+ print("ℹ️ Supabase μ‚¬μš© μ‹œ: ν™˜κ²½ λ³€μˆ˜λ₯Ό ν™•μΈν•΄μ£Όμ„Έμš”")
40
 
41
  # Gemini 평가기 μ΄ˆκΈ°ν™”
42
  evaluator = GeminiEvaluator()
 
338
  if not scenario_id:
339
  return None, None, ""
340
 
341
+ supabase_service = SupabaseService()
 
 
 
 
342
 
343
+ scenario = supabase_service.get_scenario(scenario_id)
344
+ patient = supabase_service.get_patient(scenario['patient_id']) if scenario else None
345
 
346
  if not scenario or not patient:
347
  return None, None, "μ‹œλ‚˜λ¦¬μ˜€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."
348
 
349
+ # Supabase 데이터λ₯Ό 객체처럼 μ‚¬μš©ν•  수 μžˆλ„λ‘ λž˜ν•‘
350
+ class Scenario:
351
+ def __init__(self, data):
352
+ self.id = data['id']
353
+ self.patient_id = data['patient_id']
354
+ self.title = data['title']
355
+ self.hospital_day = data.get('hospital_day')
356
+ self.vitals = data.get('vitals')
357
+ self.labs = data.get('labs')
358
+ self.orders = data.get('orders')
359
+ self.nursing_notes = data.get('nursing_notes')
360
+ self.surgery_info = data.get('surgery_info')
361
+ self.discharge_education = data.get('discharge_education')
362
+
363
+ class Patient:
364
+ def __init__(self, data):
365
+ self.id = data['id']
366
+ self.name = data['name']
367
+ self.age = data['age']
368
+ self.gender = data['gender']
369
+ self.diagnosis = data['diagnosis']
370
+ self.admission_date = data['admission_date']
371
+ self.allergies = data['allergies']
372
+ self.comorbidities = data.get('comorbidities', '')
373
+ self.attending_physician = data.get('attending_physician', '')
374
+ self.room_number = data.get('room_number', '')
375
+
376
+ scenario_obj = Scenario(scenario)
377
+ patient_obj = Patient(patient)
378
+ emr_display = create_emr_html(patient_obj, scenario_obj)
379
+ return scenario_obj, patient_obj, emr_display
380
 
381
 
382
  def handle_chat_message(message, chat_history, session_id, student_id, scenario_id):
 
397
  """
398
  return chat_history + [[message, warning_msg]], ""
399
 
400
+ # Supabase μ„œλΉ„μŠ€ μ—°κ²°
401
+ supabase_service = SupabaseService()
 
402
 
403
  # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 정보 쑰회
404
+ scenario = supabase_service.get_scenario(scenario_id)
405
+ patient = supabase_service.get_patient(scenario['patient_id']) if scenario else None
406
 
407
  if not scenario or not patient:
 
408
  error_msg = """
409
  ❌ **μ‹œλ‚˜λ¦¬μ˜€ 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.**
410
 
 
413
  return chat_history + [[message, error_msg]], ""
414
 
415
  # μ‚¬μš©μž λ©”μ‹œμ§€ μ €μž₯
416
+ supabase_service.save_chat_message(session_id, student_id, scenario_id, "user", message)
417
 
418
  # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 데이터 μ€€λΉ„
419
  scenario_data = {
420
+ "title": scenario['title'],
421
+ "handoff_situation": scenario.get('handoff_situation'),
422
+ "hospital_day": scenario.get('hospital_day'),
423
+ "vitals": scenario.get('vitals'),
424
+ "labs": scenario.get('labs'),
425
+ "orders": scenario.get('orders'),
426
+ "nursing_notes": scenario.get('nursing_notes'),
427
  }
428
 
429
  patient_data = {
430
+ "name": patient['name'],
431
+ "age": patient['age'],
432
+ "gender": patient['gender'],
433
+ "diagnosis": patient['diagnosis'],
434
+ "admission_date": str(patient['admission_date']),
435
+ "allergies": patient['allergies'],
436
  }
437
 
438
  # 이전 μ±„νŒ… 기둝 ν¬λ§·νŒ… (Gemini용)
 
447
  ai_response = evaluator.chat_feedback(message, history_for_gemini, scenario_data, patient_data)
448
 
449
  # AI 응닡 μ €μž₯
450
+ supabase_service.save_chat_message(session_id, student_id, scenario_id, "assistant", ai_response)
 
 
451
 
452
  # μ±„νŒ… νžˆμŠ€ν† λ¦¬ μ—…λ°μ΄νŠΈ
453
  updated_history = chat_history + [[message, ai_response]]
 
455
  return updated_history, ""
456
 
457
 
458
+ def finalize_chat_session(chat_history, session_id, student_id, scenario_id):
459
+ """
460
+ μ±„νŒ… μ’…λ£Œ μ‹œ 전체 λŒ€ν™”λ₯Ό λΆ„μ„ν•˜μ—¬ ν”Όλ“œλ°± 제곡
461
+ - 점수 없이 질적 ν”Όλ“œλ°±λ§Œ 제곡
462
+ - 전체 λŒ€ν™” λ‚΄μš©μ„ Geminiμ—κ²Œ 전달
463
+ """
464
  if not chat_history:
465
+ return chat_history + [[None, "λŒ€ν™”κ°€ μ—†μŠ΅λ‹ˆλ‹€. λ¨Όμ € λŒ€ν™”λ₯Ό μ‹œμž‘ν•΄μ£Όμ„Έμš”."]], ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
+ # Supabase μ„œλΉ„μŠ€ μ—°κ²°
468
+ supabase_service = SupabaseService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
 
470
  # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 정보 쑰회
471
+ scenario = supabase_service.get_scenario(scenario_id)
472
+ patient = supabase_service.get_patient(scenario['patient_id']) if scenario else None
473
 
474
  if not scenario or not patient:
475
+ return chat_history + [[None, "μ‹œλ‚˜λ¦¬μ˜€ 정보λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€."]], ""
 
476
 
477
  # μ‹œλ‚˜λ¦¬μ˜€ 및 ν™˜μž 데이터 μ€€λΉ„
478
  scenario_data = {
479
+ "title": scenario['title'],
480
+ "handoff_situation": scenario.get('handoff_situation'),
481
+ "hospital_day": scenario.get('hospital_day'),
482
+ "vitals": scenario.get('vitals'),
483
+ "labs": scenario.get('labs'),
484
+ "orders": scenario.get('orders'),
485
+ "nursing_notes": scenario.get('nursing_notes'),
486
  }
487
 
488
  patient_data = {
489
+ "name": patient['name'],
490
+ "age": patient['age'],
491
+ "gender": patient['gender'],
492
+ "diagnosis": patient['diagnosis'],
493
+ "admission_date": str(patient['admission_date']),
494
+ "allergies": patient['allergies'],
495
  }
496
 
497
+ # 전체 λŒ€ν™”λ₯Ό Geminiμ—κ²Œ μ „λ‹¬ν•˜μ—¬ 평가
498
+ evaluation = evaluator.evaluate_conversation(
499
+ chat_history=chat_history,
500
+ scenario_data=scenario_data,
501
+ patient_data=patient_data
502
+ )
503
 
504
+ # λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯ (점수 없이)
505
  try:
506
+ record = supabase_service.create_handoff_record(
507
  student_id=student_id,
508
  scenario_id=scenario_id,
509
+ sbar_data={}, # λŒ€ν™” 기반 ν‰κ°€μ΄λ―€λ‘œ SBAR λ°μ΄ν„°λŠ” 비움
510
  evaluation=evaluation,
511
  session_id=session_id
512
  )
513
+ print(f"βœ… μΈμˆ˜μΈκ³„ 기둝 μ €μž₯ μ™„λ£Œ (ID: {record.get('id')})")
514
+
515
+ # μ„Έμ…˜ μƒνƒœλ₯Ό 'completed'둜 λ³€κ²½
516
+ supabase_service.update_session_status(session_id, 'completed')
517
  except Exception as e:
518
  print(f"⚠️ λ°μ΄ν„°λ² μ΄μŠ€ μ €μž₯ 였λ₯˜: {e}")
519
 
520
+ # ν”Όλ“œλ°± λ©”μ‹œμ§€ ν¬λ§·νŒ… (점수 없이)
521
+ strengths = evaluation.get('strengths', [])
522
+ improvements = evaluation.get('improvements', [])
523
+ detailed_feedback = evaluation.get('detailed_feedback', '')
524
+ missing_info = evaluation.get('missing_critical_info', [])
525
+ safety_concerns = evaluation.get('safety_concerns', [])
 
 
526
 
527
  result_message = f"""
528
+ πŸŽ“ **ν•™μŠ΅ μ’…ν•© ν”Όλ“œλ°±**
529
 
530
+ {'='*50}
531
 
532
+ ### πŸ’ͺ μž˜ν•œ 점
533
+ {chr(10).join([f"- {s}" for s in strengths]) if strengths else "- μ—†μŒ"}
 
 
 
534
 
535
+ ### πŸ“ˆ κ°œμ„ ν•  점
536
+ {chr(10).join([f"- {i}" for i in improvements]) if improvements else "- μ—†μŒ"}
537
 
538
+ ### πŸ” μ’…ν•© 의견
539
+ {detailed_feedback if detailed_feedback else "μ’…ν•© ν”Όλ“œλ°±μ΄ μ—†μŠ΅λ‹ˆλ‹€."}
540
 
541
+ """
542
+
543
+ if missing_info:
544
+ result_message += f"""
545
+ ### ⚠️ λˆ„λ½λœ μ€‘μš” 정보
546
+ {chr(10).join([f"- {m}" for m in missing_info])}
547
 
 
 
548
  """
549
 
550
+ if safety_concerns:
551
+ result_message += f"""
552
+ ### πŸ₯ ν™˜μž μ•ˆμ „ κ΄€λ ¨ μ£Όμ˜μ‚¬ν•­
553
+ {chr(10).join([f"- {s}" for s in safety_concerns])}
554
+
555
+ """
556
+
557
+ result_message += f"""
558
+ {'='*50}
559
+
560
+ μˆ˜κ³ ν•˜μ…¨μŠ΅λ‹ˆλ‹€! λ‹€λ₯Έ μ‹œλ‚˜λ¦¬μ˜€λ‘œ μ—°μŠ΅μ„ κ³„μ†ν•˜μ‹œλ €λ©΄
561
+ EMR νŽ˜μ΄μ§€λ‘œ λŒμ•„κ°€ μƒˆλ‘œμš΄ μ‹œλ‚˜λ¦¬μ˜€λ₯Ό μ„ νƒν•˜μ„Έμš”.
562
+ """
563
+
564
+ return chat_history + [[None, result_message]], ""
565
 
566
 
567
  def get_student_history(student_id: str):
 
569
  if not student_id:
570
  return "학생 IDλ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”."
571
 
572
+ supabase_service = SupabaseService()
 
573
 
574
+ stats = supabase_service.get_student_statistics(student_id)
575
+ records = supabase_service.get_records_by_student(student_id)
 
 
576
 
577
  if stats["total_submissions"] == 0:
578
  return f"학생 ID '{student_id}'의 제좜 기둝이 μ—†μŠ΅λ‹ˆλ‹€."
 
592
  """
593
 
594
  for i, record in enumerate(records[:10], 1):
595
+ submitted_time = record.get('submitted_at', 'N/A')
596
+ if submitted_time and isinstance(submitted_time, str):
597
+ # ISO ν˜•μ‹μ—μ„œ μ‹œκ°„ μΆ”μΆœ
598
+ try:
599
+ from datetime import datetime
600
+ dt = datetime.fromisoformat(submitted_time.replace('Z', '+00:00'))
601
+ submitted_time = dt.strftime("%Y-%m-%d %H:%M")
602
+ except:
603
+ pass
604
  history += f"""
605
+ ### {i}. {record.get('scenario_id', 'N/A')}
606
  - **제좜 μ‹œκ°„**: {submitted_time}
607
+ - **점수**: {record.get('total_score', 0):.1f}점
608
+ - **강점**: {', '.join(record.get('strengths', [])[:2]) if record.get('strengths') else 'N/A'}
609
  ---
610
  """
611
 
 
678
 
679
  # νŽ˜μ΄μ§€ 2: μ±„νŒ… 기반 λ‹΅μ•ˆ μž‘μ„±
680
  with gr.Tab("πŸ’¬ λ‹΅μ•ˆ μž‘μ„± (μ±„νŒ…)", id=1) as tab_chat:
681
+ gr.Markdown("## AI κ΅μˆ˜μ™€ ν•¨κ»˜ν•˜λŠ” μΈμˆ˜μΈκ³„ μ—°μŠ΅")
682
  gr.Markdown("""
683
  **πŸ“Œ μ‹œμž‘ν•˜κΈ°:**
684
  1. κΈ°λ³Έ μ‹œλ‚˜λ¦¬μ˜€λŠ” **"Day 0 - 응급싀→병동 인계"**μž…λ‹ˆλ‹€ (μƒλ‹¨μ—μ„œ 확인 κ°€λŠ₯)
685
  2. EMR 정보λ₯Ό ν™•μΈν•˜λ €λ©΄ **"EMR 정보 쑰회"** 탭을 ν΄λ¦­ν•˜μ„Έμš”
686
+ 3. μ€€λΉ„κ°€ 되면 AI κ΅μˆ˜μ™€ 자유둭게 λŒ€ν™”ν•˜λ©° μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜μ„Έμš”
687
  """)
688
 
689
  with gr.Row():
 
699
  with gr.Row():
700
  msg_input = gr.Textbox(
701
  label="λ©”μ‹œμ§€ μž…λ ₯",
702
+ placeholder="자유둭게 λŒ€ν™”ν•˜λ©° μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜κ±°λ‚˜, AI κ΅μˆ˜μ—κ²Œ μ§ˆλ¬Έν•˜μ„Έμš”...",
703
  lines=3,
704
  scale=4
705
  )
706
 
707
  with gr.Row():
708
  send_btn = gr.Button("전솑", variant="primary")
709
+ finalize_btn = gr.Button("μ±„νŒ… μ’…λ£Œ 및 평가받기", variant="stop")
710
  clear_btn = gr.Button("λŒ€ν™” μ΄ˆκΈ°ν™”")
711
 
712
  gr.Markdown("""
713
  **μ‚¬μš© 방법:**
714
+ 1. 자유둭게 λŒ€ν™”ν•˜λ©° μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜μ„Έμš”
715
+ 2. AI κ΅μˆ˜κ°€ μ¦‰μ‹œ ν”Όλ“œλ°±μ„ μ œκ³΅ν•©λ‹ˆλ‹€
716
+ 3. ν”Όλ“œλ°±μ„ λ°”νƒ•μœΌλ‘œ μˆ˜μ •ν•˜κ³  더 λ‚˜μ€ λ‚΄μš©μ„ μž‘μ„±ν•  수 μžˆμŠ΅λ‹ˆλ‹€
717
+ 4. μ€€λΉ„κ°€ 되면 **μ±„νŒ… μ’…λ£Œ 및 평가받기** λ²„νŠΌμ„ 눌러 μ’…ν•© ν”Όλ“œλ°±μ„ λ°›μœΌμ„Έμš”
 
 
 
 
 
 
 
 
718
 
719
+ **πŸ’‘ 팁:**
720
+ - μžμ—°μŠ€λŸ¬μš΄ λŒ€ν™”λ‘œ λ°°μš°λŠ” 것이 ν•΅μ‹¬μž…λ‹ˆλ‹€
721
+ - SBAR ν˜•μ‹μ„ κ°•μ œν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λŒ€ν™”λ₯Ό 톡해 μžλ™μœΌλ‘œ ν‰κ°€λ©λ‹ˆλ‹€
722
+ - μ–Έμ œλ“ μ§€ AI κ΅μˆ˜μ—κ²Œ μ§ˆλ¬Έν•˜μ„Έμš”
 
 
723
  """)
724
 
725
  # νŽ˜μ΄μ§€ 3: κ΄€λ¦¬μž 이λ ₯ 쑰회
 
756
 
757
  if scenario:
758
  # μƒˆ μ±„νŒ… μ„Έμ…˜ 생성
759
+ supabase_service = SupabaseService()
760
+ new_session_id = supabase_service.create_chat_session(student_id, scenario_id)
 
 
761
 
762
  scenario_info = f"**ν˜„μž¬ μ‹œλ‚˜λ¦¬μ˜€**: {scenario.title}"
763
 
 
786
  show_progress="minimal"
787
  )
788
 
789
+ # μ±„νŒ… μ’…λ£Œ 및 평가
790
+ finalize_btn.click(
791
+ fn=finalize_chat_session,
792
+ inputs=[chatbot, session_id_state, student_id_state, scenario_id_state],
793
  outputs=[chatbot, msg_input]
794
  )
795
 
796
  # λŒ€ν™” μ΄ˆκΈ°ν™”
797
  def clear_chat(student_id, scenario_id):
798
  """λŒ€ν™” μ΄ˆκΈ°ν™” 및 μƒˆ μ„Έμ…˜ 생성"""
799
+ supabase_service = SupabaseService()
800
+ new_session_id = supabase_service.create_chat_session(student_id, scenario_id)
 
 
801
 
802
  return [], new_session_id
803
 
config/database.py CHANGED
@@ -1,53 +1,82 @@
1
  """
2
- λ°μ΄ν„°λ² μ΄μŠ€ μ—°κ²° μ„€μ •
3
  """
4
 
5
  import os
6
 
7
  from dotenv import load_dotenv
8
- from sqlalchemy import create_engine
9
- from sqlalchemy.ext.declarative import declarative_base
10
- from sqlalchemy.orm import sessionmaker
 
 
 
11
 
12
  # ν™˜κ²½ λ³€μˆ˜ λ‘œλ“œ
13
  load_dotenv()
14
 
15
- # λ°μ΄ν„°λ² μ΄μŠ€ URL ꡬ성
16
- DB_HOST = os.getenv("DB_HOST", "localhost")
17
- DB_PORT = os.getenv("DB_PORT", "5433")
18
- DB_NAME = os.getenv("DB_NAME", "nursing_handoff")
19
- DB_USER = os.getenv("DB_USER", "postgres")
20
- DB_PASSWORD = os.getenv("DB_PASSWORD", "postgres123")
21
-
22
- DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
23
 
24
- # SQLAlchemy μ—”μ§„ 및 μ„Έμ…˜ 생성
25
- engine = create_engine(DATABASE_URL, echo=True)
26
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
27
 
28
- # Base 클래슀
29
- Base = declarative_base()
30
 
31
-
32
- def get_db():
33
  """
34
- λ°μ΄ν„°λ² μ΄μŠ€ μ„Έμ…˜μ„ μƒμ„±ν•˜κ³  λ°˜ν™˜ν•˜λŠ” μ œλ„ˆλ ˆμ΄ν„°
 
 
 
35
  """
36
- db = SessionLocal()
 
 
37
  try:
38
- yield db
39
- finally:
40
- db.close()
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
- def init_db():
44
- """
45
- λ°μ΄ν„°λ² μ΄μŠ€ ν…Œμ΄λΈ” 생성
46
- """
47
- # Import models to register them with SQLAlchemy
48
- import models.chat_history # noqa: F401
49
- import models.handoff_record # noqa: F401
50
- import models.patient_data # noqa: F401
51
 
52
- Base.metadata.create_all(bind=engine)
53
- print("βœ… λ°μ΄ν„°λ² μ΄μŠ€ ν…Œμ΄λΈ”μ΄ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Supabase REST API μ„€μ •
3
  """
4
 
5
  import os
6
 
7
  from dotenv import load_dotenv
8
+
9
+ try:
10
+ from supabase import Client, create_client
11
+ except ImportError:
12
+ create_client = None
13
+ Client = None
14
 
15
  # ν™˜κ²½ λ³€μˆ˜ λ‘œλ“œ
16
  load_dotenv()
17
 
18
+ # Supabase μ—°κ²° μ„€μ •
19
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
20
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
 
 
 
 
 
21
 
22
+ # SUPABASE_KEY도 지원 (ν˜Έν™˜μ„±)
23
+ if not SUPABASE_ANON_KEY:
24
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_KEY")
25
 
 
 
26
 
27
+ def check_supabase_health() -> bool:
 
28
  """
29
+ Supabase μ—°κ²° μƒνƒœ 확인
30
+
31
+ Returns:
32
+ bool: Supabase μ‚¬μš© κ°€λŠ₯ μ—¬λΆ€
33
  """
34
+ if not SUPABASE_URL:
35
+ return False
36
+
37
  try:
38
+ import requests
 
 
39
 
40
+ # μ—¬λŸ¬ μ—”λ“œν¬μΈνŠΈ μ‹œλ„
41
+ health_endpoints = [
42
+ "/rest/v1/",
43
+ "/auth/v1/health",
44
+ "/health"
45
+ ]
46
+
47
+ for endpoint in health_endpoints:
48
+ try:
49
+ health_url = f"{SUPABASE_URL}{endpoint}"
50
+ response = requests.get(health_url, timeout=3)
51
+ if response.status_code in [200, 301, 302, 404]:
52
+ print(f"βœ… Supabase μ—°κ²° 확인: {endpoint}")
53
+ return True
54
+ except Exception:
55
+ continue
56
+
57
+ return False
58
+ except Exception as e:
59
+ print(f"⚠️ Supabase ν—¬μŠ€μ²΄ν¬ μ‹€νŒ¨: {e}")
60
+ return False
61
 
 
 
 
 
 
 
 
 
62
 
63
+ # Supabase ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” (REST API μ‚¬μš©)
64
+ supabase_client: Client | None = None
65
+
66
+ if SUPABASE_URL and SUPABASE_ANON_KEY and create_client:
67
+ try:
68
+ supabase_client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
69
+ print("βœ… Supabase REST API ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” μ™„λ£Œ")
70
+
71
+ # Supabase μ—°κ²° μƒνƒœ 확인
72
+ is_supabase = check_supabase_health()
73
+ if is_supabase:
74
+ print("βœ… Supabase μ—°κ²° 확인 μ™„λ£Œ")
75
+ else:
76
+ print("⚠️ Supabase ν—¬μŠ€μ²΄ν¬ μ‹€νŒ¨, REST APIλŠ” μ‚¬μš© κ°€λŠ₯ν•©λ‹ˆλ‹€")
77
+ except Exception as e:
78
+ print(f"⚠️ Supabase REST API ν΄λΌμ΄μ–ΈνŠΈ μ΄ˆκΈ°ν™” μ‹€νŒ¨: {e}")
79
+ supabase_client = None
80
+ else:
81
+ print("⚠️ Supabase ν™˜κ²½ λ³€μˆ˜κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
82
+ print(" SUPABASE_URLκ³Ό SUPABASE_ANON_KEYλ₯Ό μ„€μ •ν•˜μ„Έμš”.")
docker-compose.yml DELETED
@@ -1,23 +0,0 @@
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 CHANGED
@@ -1,23 +1,28 @@
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()
 
1
  """
2
+ Supabase μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ 슀크립트
3
  """
4
 
5
+ from services.supabase_service import SupabaseService
 
6
 
7
+ from config.database import supabase_client
 
8
 
9
+ if __name__ == "__main__":
10
+ print("πŸš€ Supabase μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ μ‹œμž‘...")
11
+
12
+ if not supabase_client:
13
+ print("❌ Supabase ν΄λΌμ΄μ–ΈνŠΈκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.")
14
+ print(" ν™˜κ²½ λ³€μˆ˜ SUPABASE_URLκ³Ό SUPABASE_ANON_KEYλ₯Ό ν™•μΈν•˜μ„Έμš”.")
15
+ exit(1)
16
 
17
+ # Supabase μ„œλΉ„μŠ€ μ΄ˆκΈ°ν™”
18
+ supabase_service = SupabaseService()
19
+
20
  # μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ
21
  print("\nπŸ“₯ μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ 쀑...")
 
22
  try:
23
+ supabase_service.load_scenarios_from_json("data/scenarios.json")
24
  print("\nβœ… λͺ¨λ“  μ΄ˆκΈ°ν™” μž‘μ—…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€!")
25
  except Exception as e:
26
  print(f"\n❌ 였λ₯˜ λ°œμƒ: {e}")
27
+ import traceback
28
+ traceback.print_exc()
models/__init__.py DELETED
File without changes
models/chat_history.py DELETED
@@ -1,44 +0,0 @@
1
- """
2
- μ±„νŒ… νžˆμŠ€ν† λ¦¬ λͺ¨λΈ
3
- """
4
-
5
- from config.database import Base
6
- from sqlalchemy import Column, DateTime, Integer, String, Text
7
- from sqlalchemy.sql import func
8
-
9
-
10
- class ChatHistory(Base):
11
- """학생과 AI κ°„μ˜ μ±„νŒ… 기둝"""
12
-
13
- __tablename__ = "chat_history"
14
-
15
- id = Column(Integer, primary_key=True, autoincrement=True)
16
- session_id = Column(String(100), nullable=False, index=True) # μ±„νŒ… μ„Έμ…˜ ID
17
- student_id = Column(String(50), nullable=False, index=True) # 학생 μ‹λ³„μž
18
- scenario_id = Column(String(50), nullable=False) # μ‹œλ‚˜λ¦¬μ˜€ ID
19
- role = Column(String(20), nullable=False) # 'user' λ˜λŠ” 'assistant'
20
- message = Column(Text, nullable=False) # λ©”μ‹œμ§€ λ‚΄μš©
21
- timestamp = Column(DateTime(timezone=True), server_default=func.now())
22
-
23
- def __repr__(self):
24
- return f"<ChatHistory(id={self.id}, session_id={self.session_id}, role={self.role})>"
25
-
26
-
27
- class SbarDraft(Base):
28
- """μž‘μ„± 쀑인 SBAR μž„μ‹œ μ €μž₯"""
29
-
30
- __tablename__ = "sbar_drafts"
31
-
32
- id = Column(Integer, primary_key=True, autoincrement=True)
33
- session_id = Column(String(100), nullable=False, unique=True, index=True)
34
- student_id = Column(String(50), nullable=False)
35
- scenario_id = Column(String(50), nullable=False)
36
- situation = Column(Text, default="")
37
- background = Column(Text, default="")
38
- assessment = Column(Text, default="")
39
- recommendation = Column(Text, default="")
40
- updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
41
-
42
- def __repr__(self):
43
- return f"<SbarDraft(session_id={self.session_id}, student_id={self.student_id})>"
44
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/handoff_record.py DELETED
@@ -1,42 +0,0 @@
1
- """
2
- μΈμˆ˜μΈκ³„ 기둝 λͺ¨λΈ
3
- """
4
-
5
- from config.database import Base
6
- from sqlalchemy import (JSON, Column, DateTime, Float, ForeignKey, Integer,
7
- String, Text)
8
- from sqlalchemy.orm import relationship
9
- from sqlalchemy.sql import func
10
-
11
-
12
- class HandoffRecord(Base):
13
- """ν•™μƒμ˜ μΈμˆ˜μΈκ³„ 기둝 및 평가 κ²°κ³Ό"""
14
-
15
- __tablename__ = "handoff_records"
16
-
17
- id = Column(Integer, primary_key=True, autoincrement=True)
18
- session_id = Column(String(100), nullable=True, index=True) # μ±„νŒ… μ„Έμ…˜ ID (μ±„νŒ… 기둝과 μ—°κ²°)
19
- student_id = Column(String(50), nullable=False) # 학생 μ‹λ³„μž
20
- scenario_id = Column(String(50), ForeignKey("scenarios.id"), nullable=False)
21
-
22
- # 제좜 μ‹œκ°„
23
- submitted_at = Column(DateTime(timezone=True), server_default=func.now())
24
-
25
- # SBAR μž…λ ₯ λ‚΄μš©
26
- situation = Column(Text, nullable=False)
27
- background = Column(Text, nullable=False)
28
- assessment = Column(Text, nullable=False)
29
- recommendation = Column(Text, nullable=False)
30
-
31
- # Gemini AI 평가 κ²°κ³Ό
32
- total_score = Column(Float) # 총점 (0-100)
33
- category_scores = Column(JSON) # μΉ΄ν…Œκ³ λ¦¬λ³„ 점수
34
- strengths = Column(JSON) # 강점 λͺ©λ‘
35
- improvements = Column(JSON) # κ°œμ„ μ‚¬ν•­ λͺ©λ‘
36
- detailed_feedback = Column(Text) # 상세 ν”Όλ“œλ°±
37
-
38
- # 관계
39
- scenario = relationship("Scenario", back_populates="handoff_records")
40
-
41
- def __repr__(self):
42
- return f"<HandoffRecord(id={self.id}, student_id={self.student_id}, score={self.total_score})>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/patient_data.py DELETED
@@ -1,65 +0,0 @@
1
- """
2
- ν™˜μž 및 μ‹œλ‚˜λ¦¬μ˜€ 데이터 λͺ¨λΈ
3
- """
4
-
5
- from config.database import Base
6
- from sqlalchemy import JSON, Column, Date, ForeignKey, Integer, String, Text
7
- from sqlalchemy.orm import relationship
8
-
9
-
10
- class Patient(Base):
11
- """ν™˜μž κΈ°λ³Έ 정보"""
12
-
13
- __tablename__ = "patients"
14
-
15
- id = Column(String(50), primary_key=True) # 예: P001
16
- name = Column(String(100), nullable=False)
17
- age = Column(Integer, nullable=False)
18
- gender = Column(String(10), nullable=False)
19
- weight = Column(Integer) # kg
20
- height = Column(Integer) # cm
21
- diagnosis = Column(String(200), nullable=False)
22
- admission_date = Column(Date, nullable=False)
23
- attending_physician = Column(String(100))
24
- allergies = Column(String(200))
25
- comorbidities = Column(Text)
26
- room_number = Column(String(20))
27
-
28
- # 관계
29
- scenarios = relationship("Scenario", back_populates="patient")
30
-
31
- def __repr__(self):
32
- return f"<Patient(id={self.id}, name={self.name}, diagnosis={self.diagnosis})>"
33
-
34
-
35
- class Scenario(Base):
36
- """ν™˜μž μ‹œλ‚˜λ¦¬μ˜€ (μΈμˆ˜μΈκ³„ 상황)"""
37
-
38
- __tablename__ = "scenarios"
39
-
40
- id = Column(String(50), primary_key=True) # 예: S001_D0_ER_WARD
41
- patient_id = Column(String(50), ForeignKey("patients.id"), nullable=False)
42
- title = Column(String(200), nullable=False)
43
- day = Column(Integer, nullable=False) # 0, 1, 2
44
- handoff_situation = Column(String(200)) # 예: 응급싀→병동
45
-
46
- # EMR 데이터 (JSON ν˜•μ‹)
47
- vitals = Column(JSON) # ν™œλ ₯μ§•ν›„
48
- labs = Column(JSON) # 검사 κ²°κ³Ό
49
- orders = Column(JSON) # μ˜μ‚¬ 처방
50
- nursing_notes = Column(JSON) # κ°„ν˜Έ 기둝
51
- physical_exam = Column(JSON) # 신체 κ²€μ§„
52
- imaging = Column(JSON) # μ˜μƒ 검사
53
- surgery_info = Column(JSON) # 수술 정보
54
- discharge_education = Column(JSON) # 퇴원 ꡐ윑
55
-
56
- # μΆ”κ°€ 정보
57
- hospital_day = Column(Integer)
58
- status_badge = Column(String(20)) # stable, watch, critical
59
-
60
- # 관계
61
- patient = relationship("Patient", back_populates="scenarios")
62
- handoff_records = relationship("HandoffRecord", back_populates="scenario")
63
-
64
- def __repr__(self):
65
- return f"<Scenario(id={self.id}, title={self.title})>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -6,7 +6,8 @@ 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
  ]
 
6
  dependencies = [
7
  "google-generativeai>=0.8.5",
8
  "gradio>=5.49.1",
 
9
  "python-dotenv>=1.1.1",
10
+ "requests>=2.31.0",
11
+ "supabase>=2.0.0",
12
+ "tsidpy>=1.1.5",
13
  ]
services/db_service.py DELETED
@@ -1,296 +0,0 @@
1
- """
2
- λ°μ΄ν„°λ² μ΄μŠ€ CRUD μ„œλΉ„μŠ€
3
- """
4
-
5
- import uuid
6
- from datetime import datetime
7
-
8
- from sqlalchemy.orm import Session
9
-
10
- from models.chat_history import ChatHistory, SbarDraft
11
- from models.handoff_record import HandoffRecord
12
- from models.patient_data import Patient, Scenario
13
-
14
-
15
- class DatabaseService:
16
- """λ°μ΄ν„°λ² μ΄μŠ€ κ΄€λ ¨ μ„œλΉ„μŠ€"""
17
-
18
- def __init__(self, db: Session):
19
- self.db = db
20
-
21
- # ===== Patient κ΄€λ ¨ =====
22
-
23
- def create_patient(self, patient_data: dict) -> Patient:
24
- """ν™˜μž 생성"""
25
- patient = Patient(**patient_data)
26
- self.db.add(patient)
27
- self.db.commit()
28
- self.db.refresh(patient)
29
- return patient
30
-
31
- def get_patient(self, patient_id: str) -> Patient:
32
- """ν™˜μž 쑰회"""
33
- return self.db.query(Patient).filter(Patient.id == patient_id).first()
34
-
35
- def get_all_patients(self):
36
- """λͺ¨λ“  ν™˜μž 쑰회"""
37
- return self.db.query(Patient).all()
38
-
39
- # ===== Scenario κ΄€λ ¨ =====
40
-
41
- def create_scenario(self, scenario_data: dict) -> Scenario:
42
- """μ‹œλ‚˜λ¦¬μ˜€ 생성"""
43
- scenario = Scenario(**scenario_data)
44
- self.db.add(scenario)
45
- self.db.commit()
46
- self.db.refresh(scenario)
47
- return scenario
48
-
49
- def get_scenario(self, scenario_id: str) -> Scenario:
50
- """μ‹œλ‚˜λ¦¬μ˜€ 쑰회"""
51
- return self.db.query(Scenario).filter(Scenario.id == scenario_id).first()
52
-
53
- def get_scenarios_by_patient(self, patient_id: str):
54
- """νŠΉμ • ν™˜μžμ˜ λͺ¨λ“  μ‹œλ‚˜λ¦¬μ˜€ 쑰회"""
55
- return self.db.query(Scenario).filter(Scenario.patient_id == patient_id).all()
56
-
57
- def get_all_scenarios(self):
58
- """λͺ¨λ“  μ‹œλ‚˜λ¦¬μ˜€ 쑰회"""
59
- return self.db.query(Scenario).all()
60
-
61
- # ===== HandoffRecord κ΄€λ ¨ =====
62
-
63
- def create_handoff_record(
64
- self, student_id: str, scenario_id: str, sbar_data: dict, evaluation: dict, session_id: str = None
65
- ) -> HandoffRecord:
66
- """μΈμˆ˜μΈκ³„ 기둝 생성"""
67
- record = HandoffRecord(
68
- session_id=session_id,
69
- student_id=student_id,
70
- scenario_id=scenario_id,
71
- situation=sbar_data.get("situation", ""),
72
- background=sbar_data.get("background", ""),
73
- assessment=sbar_data.get("assessment", ""),
74
- recommendation=sbar_data.get("recommendation", ""),
75
- total_score=evaluation.get("total_score", 0),
76
- category_scores=evaluation.get("category_scores", {}),
77
- strengths=evaluation.get("strengths", []),
78
- improvements=evaluation.get("improvements", []),
79
- detailed_feedback=evaluation.get("detailed_feedback", ""),
80
- )
81
- self.db.add(record)
82
- self.db.commit()
83
- self.db.refresh(record)
84
- return record
85
-
86
- def get_handoff_record(self, record_id: int) -> HandoffRecord:
87
- """μΈμˆ˜μΈκ³„ 기둝 쑰회"""
88
- return (
89
- self.db.query(HandoffRecord).filter(HandoffRecord.id == record_id).first()
90
- )
91
-
92
- def get_records_by_student(self, student_id: str):
93
- """νŠΉμ • ν•™μƒμ˜ λͺ¨λ“  μΈμˆ˜μΈκ³„ 기둝 쑰회"""
94
- return (
95
- self.db.query(HandoffRecord)
96
- .filter(HandoffRecord.student_id == student_id)
97
- .order_by(HandoffRecord.submitted_at.desc())
98
- .all()
99
- )
100
-
101
- def get_records_by_scenario(self, scenario_id: str):
102
- """νŠΉμ • μ‹œλ‚˜λ¦¬μ˜€μ˜ λͺ¨λ“  μΈμˆ˜μΈκ³„ 기둝 쑰회"""
103
- return (
104
- self.db.query(HandoffRecord)
105
- .filter(HandoffRecord.scenario_id == scenario_id)
106
- .order_by(HandoffRecord.submitted_at.desc())
107
- .all()
108
- )
109
-
110
- def get_all_handoff_records(self):
111
- """λͺ¨λ“  μΈμˆ˜μΈκ³„ 기둝 쑰회"""
112
- return (
113
- self.db.query(HandoffRecord)
114
- .order_by(HandoffRecord.submitted_at.desc())
115
- .all()
116
- )
117
-
118
- # ===== 톡계 κ΄€λ ¨ =====
119
-
120
- def get_student_statistics(self, student_id: str) -> dict:
121
- """학생별 톡계"""
122
- records = self.get_records_by_student(student_id)
123
-
124
- if not records:
125
- return {
126
- "total_submissions": 0,
127
- "average_score": 0,
128
- "highest_score": 0,
129
- "lowest_score": 0,
130
- "recent_submissions": [],
131
- }
132
-
133
- scores = [r.total_score for r in records if r.total_score is not None]
134
-
135
- return {
136
- "total_submissions": len(records),
137
- "average_score": sum(scores) / len(scores) if scores else 0,
138
- "highest_score": max(scores) if scores else 0,
139
- "lowest_score": min(scores) if scores else 0,
140
- "recent_submissions": [
141
- {
142
- "id": r.id,
143
- "scenario_id": r.scenario_id,
144
- "score": r.total_score,
145
- "submitted_at": r.submitted_at.isoformat()
146
- if r.submitted_at
147
- else None,
148
- }
149
- for r in records[:5] # 졜근 5개
150
- ],
151
- }
152
-
153
- def get_scenario_statistics(self, scenario_id: str) -> dict:
154
- """μ‹œλ‚˜λ¦¬μ˜€λ³„ 톡계"""
155
- records = self.get_records_by_scenario(scenario_id)
156
-
157
- if not records:
158
- return {"total_attempts": 0, "average_score": 0, "score_distribution": {}}
159
-
160
- scores = [r.total_score for r in records if r.total_score is not None]
161
-
162
- # 점수 ꡬ간별 뢄포 (0-60: 미흑, 61-80: 보톡, 81-100: 우수)
163
- distribution = {
164
- "excellent": len([s for s in scores if s >= 81]),
165
- "good": len([s for s in scores if 61 <= s < 81]),
166
- "needs_improvement": len([s for s in scores if s < 61]),
167
- }
168
-
169
- return {
170
- "total_attempts": len(records),
171
- "average_score": sum(scores) / len(scores) if scores else 0,
172
- "score_distribution": distribution,
173
- }
174
-
175
- # ===== ChatHistory κ΄€λ ¨ =====
176
-
177
- def create_chat_session(self, student_id: str, scenario_id: str) -> str:
178
- """μƒˆ μ±„νŒ… μ„Έμ…˜ 생성 및 μ„Έμ…˜ ID λ°˜ν™˜"""
179
- session_id = f"{student_id}_{scenario_id}_{uuid.uuid4().hex[:8]}"
180
- return session_id
181
-
182
- def save_chat_message(
183
- self, session_id: str, student_id: str, scenario_id: str, role: str, message: str
184
- ) -> ChatHistory:
185
- """μ±„νŒ… λ©”μ‹œμ§€ μ €μž₯"""
186
- chat = ChatHistory(
187
- session_id=session_id,
188
- student_id=student_id,
189
- scenario_id=scenario_id,
190
- role=role,
191
- message=message,
192
- )
193
- self.db.add(chat)
194
- self.db.commit()
195
- self.db.refresh(chat)
196
- return chat
197
-
198
- def get_chat_history(self, session_id: str):
199
- """νŠΉμ • μ„Έμ…˜μ˜ μ±„νŒ… 기둝 쑰회"""
200
- return (
201
- self.db.query(ChatHistory)
202
- .filter(ChatHistory.session_id == session_id)
203
- .order_by(ChatHistory.timestamp.asc())
204
- .all()
205
- )
206
-
207
- def get_chat_sessions_by_student(self, student_id: str):
208
- """νŠΉμ • ν•™μƒμ˜ λͺ¨λ“  μ±„νŒ… μ„Έμ…˜ 쑰회"""
209
- return (
210
- self.db.query(ChatHistory.session_id, ChatHistory.scenario_id, ChatHistory.timestamp)
211
- .filter(ChatHistory.student_id == student_id)
212
- .distinct(ChatHistory.session_id)
213
- .order_by(ChatHistory.session_id, ChatHistory.timestamp.desc())
214
- .all()
215
- )
216
-
217
- # ===== SbarDraft κ΄€λ ¨ =====
218
-
219
- def save_draft_sbar(
220
- self, session_id: str, student_id: str, scenario_id: str, sbar_data: dict
221
- ) -> SbarDraft:
222
- """μž‘μ„± 쀑인 SBAR μž„μ‹œ μ €μž₯"""
223
- # κΈ°μ‘΄ draftκ°€ 있으면 μ—…λ°μ΄νŠΈ, μ—†μœΌλ©΄ 생성
224
- draft = self.db.query(SbarDraft).filter(SbarDraft.session_id == session_id).first()
225
-
226
- if draft:
227
- draft.situation = sbar_data.get("situation", "")
228
- draft.background = sbar_data.get("background", "")
229
- draft.assessment = sbar_data.get("assessment", "")
230
- draft.recommendation = sbar_data.get("recommendation", "")
231
- else:
232
- draft = SbarDraft(
233
- session_id=session_id,
234
- student_id=student_id,
235
- scenario_id=scenario_id,
236
- situation=sbar_data.get("situation", ""),
237
- background=sbar_data.get("background", ""),
238
- assessment=sbar_data.get("assessment", ""),
239
- recommendation=sbar_data.get("recommendation", ""),
240
- )
241
- self.db.add(draft)
242
-
243
- self.db.commit()
244
- self.db.refresh(draft)
245
- return draft
246
-
247
- def get_draft_sbar(self, session_id: str) -> dict:
248
- """μž„μ‹œ μ €μž₯된 SBAR 뢈러였기"""
249
- draft = self.db.query(SbarDraft).filter(SbarDraft.session_id == session_id).first()
250
-
251
- if draft:
252
- return {
253
- "situation": draft.situation or "",
254
- "background": draft.background or "",
255
- "assessment": draft.assessment or "",
256
- "recommendation": draft.recommendation or "",
257
- }
258
- return {"situation": "", "background": "", "assessment": "", "recommendation": ""}
259
-
260
-
261
- def load_scenarios_to_db(db: Session, json_file_path: str):
262
- """
263
- JSON νŒŒμΌμ—μ„œ ν™˜μž 및 μ‹œλ‚˜λ¦¬μ˜€ 데이터λ₯Ό 읽어 DB에 μ €μž₯
264
- """
265
- import json
266
-
267
- with open(json_file_path, "r", encoding="utf-8") as f:
268
- data = json.load(f)
269
-
270
- db_service = DatabaseService(db)
271
-
272
- # ν™˜μž 데이터 μ €μž₯
273
- patient_data = data["patient"]
274
-
275
- # admission_dateλ₯Ό λ¬Έμžμ—΄μ—μ„œ date 객체둜 λ³€ν™˜
276
- if isinstance(patient_data.get("admission_date"), str):
277
- patient_data["admission_date"] = datetime.strptime(
278
- patient_data["admission_date"], "%Y-%m-%d"
279
- ).date()
280
-
281
- # κΈ°μ‘΄ ν™˜μžκ°€ μ—†μœΌλ©΄ 생성
282
- existing_patient = db_service.get_patient(patient_data["id"])
283
- if not existing_patient:
284
- db_service.create_patient(patient_data)
285
- print(f"βœ… ν™˜μž 생성: {patient_data['name']}")
286
- else:
287
- print(f"ℹ️ ν™˜μž 이미 쑴재: {patient_data['name']}")
288
-
289
- # μ‹œλ‚˜λ¦¬μ˜€ 데이터 μ €μž₯
290
- for scenario_data in data["scenarios"]:
291
- existing_scenario = db_service.get_scenario(scenario_data["id"])
292
- if not existing_scenario:
293
- db_service.create_scenario(scenario_data)
294
- print(f"βœ… μ‹œλ‚˜λ¦¬μ˜€ 생성: {scenario_data['title']}")
295
- else:
296
- print(f"ℹ️ μ‹œλ‚˜λ¦¬μ˜€ 이미 쑴재: {scenario_data['title']}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
services/gemini_service.py CHANGED
@@ -380,3 +380,175 @@ class GeminiEvaluator:
380
  응닡을 μž‘μ„±ν•΄μ£Όμ„Έμš”:
381
  """
382
  return prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  응닡을 μž‘μ„±ν•΄μ£Όμ„Έμš”:
381
  """
382
  return prompt
383
+
384
+ def evaluate_conversation(
385
+ self,
386
+ chat_history: list,
387
+ scenario_data: dict,
388
+ patient_data: dict
389
+ ) -> dict:
390
+ """
391
+ 전체 λŒ€ν™”λ₯Ό λΆ„μ„ν•˜μ—¬ 질적 ν”Όλ“œλ°± 제곡 (점수 μ—†μŒ)
392
+
393
+ Args:
394
+ chat_history: 전체 λŒ€ν™” 기둝 [[user_msg, assistant_msg], ...]
395
+ scenario_data: μ‹œλ‚˜λ¦¬μ˜€ 데이터 (EMR 정보)
396
+ patient_data: ν™˜μž κΈ°λ³Έ 정보
397
+
398
+ Returns:
399
+ 평가 κ²°κ³Ό λ”•μ…”λ„ˆλ¦¬ (점수 없이 질적 ν”Όλ“œλ°±λ§Œ)
400
+ """
401
+ prompt = self._create_conversation_evaluation_prompt(
402
+ chat_history, scenario_data, patient_data
403
+ )
404
+
405
+ try:
406
+ response = self.model.generate_content(
407
+ prompt,
408
+ generation_config={
409
+ "temperature": 0.7,
410
+ "top_p": 0.95,
411
+ "max_output_tokens": 2048,
412
+ },
413
+ )
414
+
415
+ # JSON 응닡 νŒŒμ‹±
416
+ result_text = response.text.strip()
417
+
418
+ # Markdown μ½”λ“œ 블둝 제거
419
+ if result_text.startswith("```"):
420
+ result_text = result_text.split("```")[1]
421
+ if result_text.startswith("json"):
422
+ result_text = result_text[4:]
423
+ result_text = result_text.strip()
424
+
425
+ evaluation = json.loads(result_text)
426
+ return evaluation
427
+
428
+ except json.JSONDecodeError as e:
429
+ print(f"JSON νŒŒμ‹± 였λ₯˜: {e}")
430
+ print(f"응닡 ν…μŠ€νŠΈ: {response.text}")
431
+ return self._create_default_evaluation_conversation()
432
+ except Exception as e:
433
+ print(f"Gemini API 였λ₯˜: {e}")
434
+ return self._create_default_evaluation_conversation()
435
+
436
+ def _create_conversation_evaluation_prompt(
437
+ self, chat_history: list, scenario_data: dict, patient_data: dict
438
+ ) -> str:
439
+ """전체 λŒ€ν™” 평가 ν”„λ‘¬ν”„νŠΈ 생성"""
440
+
441
+ # 전체 λŒ€ν™” λ‚΄μš© ν¬λ§·νŒ…
442
+ conversation_text = ""
443
+ for user_msg, bot_msg in chat_history:
444
+ if user_msg:
445
+ conversation_text += f"\n**학생**: {user_msg}\n"
446
+ if bot_msg:
447
+ conversation_text += f"\n**AI ꡐ수**: {bot_msg}\n"
448
+
449
+ prompt = f"""당신은 20λ…„ κ²½λ ₯의 ν•œκ΅­ κ°„ν˜Έ ꡐ윑 μ „λ¬Έκ°€μ΄μž κ°„ν˜Έν•™ κ΅μˆ˜μž…λ‹ˆλ‹€.
450
+ κ°„ν˜Έ 학생이 AI κ΅μˆ˜μ™€ λ‚˜λˆˆ λŒ€ν™”λ₯Ό λΆ„μ„ν•˜μ—¬ 건섀적인 μ’…ν•© ν”Όλ“œλ°±μ„ μ œκ³΅ν•΄μ£Όμ„Έμš”.
451
+
452
+ **μ€‘μš”**:
453
+ - λŒ€ν™”μ—μ„œ "학생"이 적힌 뢀뢄이 μ‹€μ œ ν•™μŠ΅ν•˜λŠ” κ°„ν˜Έ ν•™μƒμž…λ‹ˆλ‹€.
454
+ - "ν™˜μž"λŠ” EMR 정보에 λ‚˜μ˜¨ ν™˜μžμž…λ‹ˆλ‹€.
455
+ - 학생이 ν™˜μžμ— λŒ€ν•œ μΈμˆ˜μΈκ³„ λ‚΄μš©μ„ μž‘μ„±ν•˜λŠ” 것을 ν‰κ°€ν•΄μ£Όμ„Έμš”.
456
+
457
+ ## πŸ“‹ ν™˜μž 정보 (EMR 데이터 - 학생이 μΈμˆ˜μΈκ³„ν•΄μ•Ό ν•  ν™˜μž)
458
+ - **ν™˜μžλͺ…**: {patient_data.get('name')} ({patient_data.get('age')}μ„Έ {patient_data.get('gender')})
459
+ - **진단λͺ…**: {patient_data.get('diagnosis')}
460
+ - **μž…μ›μΌ**: {patient_data.get('admission_date')}
461
+ - **μ•Œλ ˆλ₯΄κΈ°**: {patient_data.get('allergies')}
462
+
463
+ ## πŸ₯ μΈμˆ˜μΈκ³„ 상황
464
+ - **μ‹œλ‚˜λ¦¬μ˜€**: {scenario_data.get('title')}
465
+ - **인계 상황**: {scenario_data.get('handoff_situation')}
466
+
467
+ ### πŸ“Š ν™œλ ₯μ§•ν›„ (Vital Signs)
468
+ {json.dumps(scenario_data.get('vitals', {}), ensure_ascii=False, indent=2)}
469
+
470
+ ### πŸ§ͺ 검사 κ²°κ³Ό (Laboratory Results)
471
+ {json.dumps(scenario_data.get('labs', {}), ensure_ascii=False, indent=2)}
472
+
473
+ ### πŸ’Š μ˜μ‚¬ 처방 (Physician Orders)
474
+ {json.dumps(scenario_data.get('orders', []), ensure_ascii=False, indent=2)}
475
+
476
+ ### πŸ“ κ°„ν˜Έ 기둝 (Nursing Notes)
477
+ {json.dumps(scenario_data.get('nursing_notes', []), ensure_ascii=False, indent=2)}
478
+
479
+ ---
480
+
481
+ ## πŸ’¬ 학생과 AI ꡐ수 κ°„ λŒ€ν™” λ‚΄μš©
482
+ **학생이 μž‘μ„±ν•œ μΈμˆ˜μΈκ³„ λ‚΄μš©κ³Ό AI ꡐ수의 ν”Όλ“œλ°±μ΄ ν¬ν•¨λœ 전체 λŒ€ν™”:**
483
+
484
+ {conversation_text if conversation_text else "(λŒ€ν™” μ—†μŒ)"}
485
+
486
+ **μœ„ λŒ€ν™”μ—μ„œ 학생이 ν™˜μž({patient_data.get('name')})에 λŒ€ν•œ μΈμˆ˜μΈκ³„λ₯Ό μ–΄λ–»κ²Œ μž‘μ„±ν–ˆλŠ”μ§€ λΆ„μ„ν•˜μ„Έμš”.**
487
+
488
+ ---
489
+
490
+ ## πŸ“ 평가 κ°€μ΄λ“œ
491
+
492
+ **μ€‘μš”:**
493
+ - ❌ 점수λ₯Ό λ§€κΈ°μ§€ λ§ˆμ„Έμš” (total_score, category_scores μ‚¬μš© κΈˆμ§€)
494
+ - βœ… 질적 ν”Όλ“œλ°±μ— μ§‘μ€‘ν•˜μ„Έμš”
495
+ - βœ… λŒ€ν™”μ—μ„œ λ‚˜νƒ€λ‚œ 강점을 ꡬ체적으둜 μ–ΈκΈ‰ν•˜μ„Έμš”
496
+ - βœ… κ°œμ„ ν•  점을 κ±΄μ„€μ μœΌλ‘œ μ œμ‹œν•˜μ„Έμš”
497
+ - βœ… λˆ„λ½λœ μ€‘μš” 정보λ₯Ό νŒŒμ•…ν•˜μ„Έμš”
498
+ - βœ… ν™˜μž μ•ˆμ „κ³Ό κ΄€λ ¨λœ μ£Όμ˜μ‚¬ν•­μ„ κ°•μ‘°ν•˜μ„Έμš”
499
+ - βœ… ν•™μŠ΅μ„ κ²©λ €ν•˜λŠ” 톀을 μœ μ§€ν•˜μ„Έμš”
500
+
501
+ **평가 κΈ°μ€€:**
502
+ 1. **μ™„μ „μ„±**: ν•„μˆ˜ 정보(ν™˜μž 신원, 진단, ν™œλ ₯μ§•ν›„, 처방 λ“±) 포함 μ—¬λΆ€
503
+ 2. **μ •ν™•μ„±**: 의료 μ •λ³΄μ˜ μ •ν™•μ„±
504
+ 3. **λͺ…λ£Œμ„±**: μ˜μ‚¬μ†Œν†΅μ˜ λͺ…ν™•μ„±
505
+ 4. **μš°μ„ μˆœμœ„**: μ€‘μš”ν•œ μ •λ³΄μ˜ μš°μ„  전달
506
+ 5. **ν™˜μž μ•ˆμ „**: ν™˜μž μ•ˆμ „οΏ½οΏ½ κ΄€λ ¨λœ 정보 κ°•μ‘°
507
+
508
+ ---
509
+
510
+ ## πŸ“€ 좜λ ₯ ν˜•μ‹ (JSON)
511
+
512
+ λ°˜λ“œμ‹œ μ•„λž˜ JSON ν˜•μ‹μœΌλ‘œλ§Œ μ‘λ‹΅ν•˜μ„Έμš”:
513
+
514
+ ```json
515
+ {{
516
+ "strengths": [
517
+ "ꡬ체적으둜 μž˜ν•œ 점 1",
518
+ "ꡬ체적으둜 μž˜ν•œ 점 2",
519
+ "ꡬ체적으둜 μž˜ν•œ 점 3"
520
+ ],
521
+ "improvements": [
522
+ "ꡬ체적으둜 κ°œμ„ ν•  점 1",
523
+ "ꡬ체적으둜 κ°œμ„ ν•  점 2",
524
+ "ꡬ체적으둜 κ°œμ„ ν•  점 3"
525
+ ],
526
+ "detailed_feedback": "ν•™μƒμ˜ μ „λ°˜μ μΈ ν•™μŠ΅ μƒνƒœμ— λŒ€ν•œ 쒅합적인 의견. 길게 μž‘μ„±ν•˜λ˜ ꡬ체적으둜.",
527
+ "missing_critical_info": [
528
+ "λˆ„λ½λœ μ€‘μš” 정보 1",
529
+ "λˆ„λ½λœ μ€‘μš” 정보 2"
530
+ ],
531
+ "safety_concerns": [
532
+ "ν™˜μž μ•ˆμ „ κ΄€λ ¨ μ£Όμ˜μ‚¬ν•­ 1",
533
+ "ν™˜μž μ•ˆμ „ κ΄€λ ¨ μ£Όμ˜μ‚¬ν•­ 2"
534
+ ]
535
+ }}
536
+ ```
537
+
538
+ **μ°Έκ³ :**
539
+ - 점수 ν•„λ“œ(total_score, category_scores)λŠ” ν¬ν•¨ν•˜μ§€ λ§ˆμ„Έμš”
540
+ - λ°°μ—΄ ν•­λͺ©μ€ μ΅œμ†Œ 2-3개 이상 μž‘μ„±ν•˜μ„Έμš”
541
+ - detailed_feedback은 200자 이상 μž‘μ„±ν•˜μ„Έμš”
542
+ - ν•œκΈ€λ‘œ μž‘μ„±ν•˜μ„Έμš”
543
+ """
544
+ return prompt
545
+
546
+ def _create_default_evaluation_conversation(self) -> dict:
547
+ """평가 μ‹€νŒ¨ μ‹œ κΈ°λ³Έ κ²°κ³Ό λ°˜ν™˜ (점수 없이)"""
548
+ return {
549
+ "strengths": ["λŒ€ν™” λ‚΄μš©μ„ λΆ„μ„ν–ˆμŠ΅λ‹ˆλ‹€."],
550
+ "improvements": ["평가 μ‹œμŠ€ν…œ 였λ₯˜λ‘œ ꡬ체적인 ν”Όλ“œλ°±μ„ μ œκ³΅ν•  수 μ—†μŠ΅λ‹ˆλ‹€."],
551
+ "detailed_feedback": "평가 μ‹œμŠ€ν…œμ— λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. λ‚˜μ€‘μ— λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.",
552
+ "missing_critical_info": [],
553
+ "safety_concerns": []
554
+ }
services/supabase_service.py ADDED
@@ -0,0 +1,647 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Supabase REST API μ„œλΉ„μŠ€
3
+
4
+ Supabase Python Clientλ₯Ό ν™œμš©ν•œ CRUD μž‘μ—…
5
+ - Row Level Security (RLS) μžλ™ 적용
6
+ - μ‹€μ‹œκ°„ ꡬ독 지원
7
+ - μžλ™ νƒ€μž… λ³€ν™˜
8
+ """
9
+
10
+ from datetime import date, datetime
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from config.database import supabase_client
14
+
15
+
16
+ class SupabaseService:
17
+ """Supabase REST APIλ₯Ό ν™œμš©ν•œ λ°μ΄ν„°λ² μ΄μŠ€ μ„œλΉ„μŠ€"""
18
+
19
+ def __init__(self):
20
+ if not supabase_client:
21
+ raise ValueError("Supabase ν΄λΌμ΄μ–ΈνŠΈκ°€ μ΄ˆκΈ°ν™”λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€. ν™˜κ²½ λ³€μˆ˜λ₯Ό ν™•μΈν•˜μ„Έμš”.")
22
+ self.client = supabase_client
23
+
24
+ # ===== Patient κ΄€λ ¨ =====
25
+
26
+ def create_patient(self, patient_data: dict) -> Dict[str, Any]:
27
+ """
28
+ ν™˜μž 생성
29
+
30
+ Args:
31
+ patient_data: ν™˜μž 정보 λ”•μ…”λ„ˆλ¦¬
32
+
33
+ Returns:
34
+ μƒμ„±λœ ν™˜μž 데이터
35
+ """
36
+ # admission_dateλ₯Ό λ¬Έμžμ—΄λ‘œ λ³€ν™˜
37
+ if isinstance(patient_data.get('admission_date'), date):
38
+ patient_data['admission_date'] = patient_data['admission_date'].isoformat()
39
+
40
+ response = self.client.table('patients').insert(patient_data).execute()
41
+ return response.data[0] if response.data else None
42
+
43
+ def get_patient(self, patient_id: str) -> Optional[Dict[str, Any]]:
44
+ """
45
+ ν™˜μž 쑰회
46
+
47
+ Args:
48
+ patient_id: ν™˜μž ID
49
+
50
+ Returns:
51
+ ν™˜μž 데이터 λ˜λŠ” None
52
+ """
53
+ response = self.client.table('patients')\
54
+ .select('*')\
55
+ .eq('id', patient_id)\
56
+ .execute()
57
+
58
+ return response.data[0] if response.data else None
59
+
60
+ def get_all_patients(self) -> List[Dict[str, Any]]:
61
+ """
62
+ λͺ¨λ“  ν™˜μž 쑰회
63
+
64
+ Returns:
65
+ ν™˜μž 데이터 리슀트
66
+ """
67
+ response = self.client.table('patients')\
68
+ .select('*')\
69
+ .order('created_at', desc=True)\
70
+ .execute()
71
+
72
+ return response.data or []
73
+
74
+ def update_patient(self, patient_id: str, update_data: dict) -> Optional[Dict[str, Any]]:
75
+ """
76
+ ν™˜μž 정보 μ—…λ°μ΄νŠΈ
77
+
78
+ Args:
79
+ patient_id: ν™˜μž ID
80
+ update_data: μ—…λ°μ΄νŠΈν•  데이터
81
+
82
+ Returns:
83
+ μ—…λ°μ΄νŠΈλœ ν™˜μž 데이터
84
+ """
85
+ response = self.client.table('patients')\
86
+ .update(update_data)\
87
+ .eq('id', patient_id)\
88
+ .execute()
89
+
90
+ return response.data[0] if response.data else None
91
+
92
+ # ===== Scenario κ΄€λ ¨ =====
93
+
94
+ def create_scenario(self, scenario_data: dict) -> Dict[str, Any]:
95
+ """
96
+ μ‹œλ‚˜λ¦¬μ˜€ 생성
97
+
98
+ Args:
99
+ scenario_data: μ‹œλ‚˜λ¦¬μ˜€ 정보 λ”•μ…”λ„ˆλ¦¬
100
+
101
+ Returns:
102
+ μƒμ„±λœ μ‹œλ‚˜λ¦¬μ˜€ 데이터
103
+ """
104
+ response = self.client.table('scenarios').insert(scenario_data).execute()
105
+ return response.data[0] if response.data else None
106
+
107
+ def get_scenario(self, scenario_id: str) -> Optional[Dict[str, Any]]:
108
+ """
109
+ μ‹œλ‚˜λ¦¬μ˜€ 쑰회
110
+
111
+ Args:
112
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
113
+
114
+ Returns:
115
+ μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ˜λŠ” None
116
+ """
117
+ response = self.client.table('scenarios')\
118
+ .select('*')\
119
+ .eq('id', scenario_id)\
120
+ .execute()
121
+
122
+ return response.data[0] if response.data else None
123
+
124
+ def get_scenarios_by_patient(self, patient_id: str) -> List[Dict[str, Any]]:
125
+ """
126
+ νŠΉμ • ν™˜μžμ˜ λͺ¨λ“  μ‹œλ‚˜λ¦¬μ˜€ 쑰회
127
+
128
+ Args:
129
+ patient_id: ν™˜μž ID
130
+
131
+ Returns:
132
+ μ‹œλ‚˜λ¦¬μ˜€ 데이터 리슀트
133
+ """
134
+ response = self.client.table('scenarios')\
135
+ .select('*')\
136
+ .eq('patient_id', patient_id)\
137
+ .order('day')\
138
+ .execute()
139
+
140
+ return response.data or []
141
+
142
+ def get_all_scenarios(self) -> List[Dict[str, Any]]:
143
+ """
144
+ λͺ¨λ“  μ‹œλ‚˜λ¦¬μ˜€ 쑰회
145
+
146
+ Returns:
147
+ μ‹œλ‚˜λ¦¬μ˜€ 데이터 리슀트
148
+ """
149
+ response = self.client.table('scenarios')\
150
+ .select('*')\
151
+ .order('created_at', desc=True)\
152
+ .execute()
153
+
154
+ return response.data or []
155
+
156
+ # ===== HandoffRecord κ΄€λ ¨ =====
157
+
158
+ def create_handoff_record(
159
+ self,
160
+ student_id: str,
161
+ scenario_id: str,
162
+ sbar_data: dict,
163
+ evaluation: dict,
164
+ session_id: Optional[str] = None
165
+ ) -> Dict[str, Any]:
166
+ """
167
+ μΈμˆ˜μΈκ³„ 기둝 생성
168
+
169
+ Args:
170
+ student_id: 학생 ID
171
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
172
+ sbar_data: SBAR 데이터
173
+ evaluation: 평가 κ²°κ³Ό
174
+ session_id: μ„Έμ…˜ ID (선택)
175
+
176
+ Returns:
177
+ μƒμ„±λœ μΈμˆ˜μΈκ³„ 기둝
178
+ """
179
+ # μ μˆ˜λŠ” 선택적 (λŒ€ν™” 기반 ν‰κ°€μ—μ„œλŠ” NULL)
180
+ record_data = {
181
+ 'session_id': session_id,
182
+ 'student_id': student_id,
183
+ 'scenario_id': scenario_id,
184
+ 'situation': sbar_data.get('situation', '') if sbar_data else '',
185
+ 'background': sbar_data.get('background', '') if sbar_data else '',
186
+ 'assessment': sbar_data.get('assessment', '') if sbar_data else '',
187
+ 'recommendation': sbar_data.get('recommendation', '') if sbar_data else '',
188
+ 'total_score': evaluation.get('total_score'), # NULL ν—ˆμš©
189
+ 'category_scores': evaluation.get('category_scores'), # NULL ν—ˆμš©
190
+ 'strengths': evaluation.get('strengths', []),
191
+ 'improvements': evaluation.get('improvements', []),
192
+ 'detailed_feedback': evaluation.get('detailed_feedback', ''),
193
+ 'missing_critical_info': evaluation.get('missing_critical_info', []), # μƒˆ ν•„λ“œ
194
+ 'safety_concerns': evaluation.get('safety_concerns', []), # μƒˆ ν•„λ“œ
195
+ }
196
+
197
+ response = self.client.table('handoff_records').insert(record_data).execute()
198
+ return response.data[0] if response.data else None
199
+
200
+ def get_handoff_record(self, record_id: int) -> Optional[Dict[str, Any]]:
201
+ """
202
+ μΈμˆ˜μΈκ³„ 기둝 쑰회
203
+
204
+ Args:
205
+ record_id: 기둝 IDγ…‚
206
+
207
+ Returns:
208
+ μΈμˆ˜μΈκ³„ 기둝 λ˜λŠ” None
209
+ """
210
+ response = self.client.table('handoff_records')\
211
+ .select('*')\
212
+ .eq('id', record_id)\
213
+ .execute()
214
+
215
+ return response.data[0] if response.data else None
216
+
217
+ def get_records_by_student(self, student_id: str) -> List[Dict[str, Any]]:
218
+ """
219
+ νŠΉμ • ν•™μƒμ˜ λͺ¨λ“  μΈμˆ˜μΈκ³„ 기둝 쑰회
220
+
221
+ Args:
222
+ student_id: 학생 ID
223
+
224
+ Returns:
225
+ μΈμˆ˜μΈκ³„ 기둝 리슀트
226
+ """
227
+ response = self.client.table('handoff_records')\
228
+ .select('*')\
229
+ .eq('student_id', student_id)\
230
+ .order('submitted_at', desc=True)\
231
+ .execute()
232
+
233
+ return response.data or []
234
+
235
+ def get_records_by_scenario(self, scenario_id: str) -> List[Dict[str, Any]]:
236
+ """
237
+ νŠΉμ • μ‹œλ‚˜λ¦¬μ˜€μ˜ λͺ¨λ“  μΈμˆ˜μΈκ³„ 기둝 쑰회
238
+
239
+ Args:
240
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
241
+
242
+ Returns:
243
+ μΈμˆ˜μΈκ³„ 기둝 리슀트
244
+ """
245
+ response = self.client.table('handoff_records')\
246
+ .select('*')\
247
+ .eq('scenario_id', scenario_id)\
248
+ .order('submitted_at', desc=True)\
249
+ .execute()
250
+
251
+ return response.data or []
252
+
253
+ # ===== ChatHistory κ΄€λ ¨ =====
254
+
255
+ def save_chat_message(
256
+ self,
257
+ session_id: str,
258
+ student_id: str,
259
+ scenario_id: str,
260
+ role: str,
261
+ message: str
262
+ ) -> Dict[str, Any]:
263
+ """
264
+ μ±„νŒ… λ©”μ‹œμ§€ μ €μž₯
265
+
266
+ Args:
267
+ session_id: μ„Έμ…˜ ID
268
+ student_id: 학생 ID
269
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
270
+ role: μ—­ν•  ('user' λ˜λŠ” 'assistant')
271
+ message: λ©”μ‹œμ§€ λ‚΄μš©
272
+
273
+ Returns:
274
+ μ €μž₯된 μ±„νŒ… λ©”μ‹œμ§€
275
+ """
276
+ chat_data = {
277
+ 'session_id': session_id,
278
+ 'student_id': student_id,
279
+ 'scenario_id': scenario_id,
280
+ 'role': role,
281
+ 'message': message,
282
+ }
283
+
284
+ response = self.client.table('chat_history').insert(chat_data).execute()
285
+ return response.data[0] if response.data else None
286
+
287
+ def get_chat_history(self, session_id: str) -> List[Dict[str, Any]]:
288
+ """
289
+ νŠΉμ • μ„Έμ…˜μ˜ μ±„νŒ… 기둝 쑰회
290
+
291
+ Args:
292
+ session_id: μ„Έμ…˜ ID
293
+
294
+ Returns:
295
+ μ±„νŒ… λ©”μ‹œμ§€ 리슀트
296
+ """
297
+ response = self.client.table('chat_history')\
298
+ .select('*')\
299
+ .eq('session_id', session_id)\
300
+ .order('timestamp')\
301
+ .execute()
302
+
303
+ return response.data or []
304
+
305
+ def get_chat_sessions_by_student(self, student_id: str) -> List[Dict[str, Any]]:
306
+ """
307
+ νŠΉμ • ν•™μƒμ˜ λͺ¨λ“  μ±„νŒ… μ„Έμ…˜ 쑰회
308
+
309
+ Args:
310
+ student_id: 학생 ID
311
+
312
+ Returns:
313
+ μ±„νŒ… μ„Έμ…˜ 리슀트
314
+ """
315
+ response = self.client.table('chat_history')\
316
+ .select('session_id, scenario_id, timestamp')\
317
+ .eq('student_id', student_id)\
318
+ .order('timestamp', desc=True)\
319
+ .execute()
320
+
321
+ # 쀑볡 제거 (μ„Έμ…˜λ³„λ‘œ κ·Έλ£Ήν™”)
322
+ sessions = {}
323
+ for chat in response.data or []:
324
+ session_id = chat['session_id']
325
+ if session_id not in sessions:
326
+ sessions[session_id] = chat
327
+
328
+ return list(sessions.values())
329
+
330
+ # ===== SbarDraft κ΄€λ ¨ =====
331
+
332
+ def save_draft_sbar(
333
+ self,
334
+ session_id: str,
335
+ student_id: str,
336
+ scenario_id: str,
337
+ sbar_data: dict
338
+ ) -> Dict[str, Any]:
339
+ """
340
+ μž‘μ„± 쀑인 SBAR μž„μ‹œ μ €μž₯ (Upsert)
341
+
342
+ Args:
343
+ session_id: μ„Έμ…˜ ID
344
+ student_id: 학생 ID
345
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
346
+ sbar_data: SBAR 데이터
347
+
348
+ Returns:
349
+ μ €μž₯된 SBAR μ΄ˆμ•ˆ
350
+ """
351
+ draft_data = {
352
+ 'session_id': session_id,
353
+ 'student_id': student_id,
354
+ 'scenario_id': scenario_id,
355
+ 'situation': sbar_data.get('situation', ''),
356
+ 'background': sbar_data.get('background', ''),
357
+ 'assessment': sbar_data.get('assessment', ''),
358
+ 'recommendation': sbar_data.get('recommendation', ''),
359
+ }
360
+
361
+ # Upsert: session_idκ°€ 있으면 μ—…λ°μ΄νŠΈ, μ—†μœΌλ©΄ μ‚½μž…
362
+ response = self.client.table('sbar_drafts')\
363
+ .upsert(draft_data, on_conflict='session_id')\
364
+ .execute()
365
+
366
+ return response.data[0] if response.data else None
367
+
368
+ def get_draft_sbar(self, session_id: str) -> Dict[str, str]:
369
+ """
370
+ μž„μ‹œ μ €μž₯된 SBAR 뢈러였기
371
+
372
+ Args:
373
+ session_id: μ„Έμ…˜ ID
374
+
375
+ Returns:
376
+ SBAR 데이터 λ”•μ…”λ„ˆλ¦¬
377
+ """
378
+ response = self.client.table('sbar_drafts')\
379
+ .select('*')\
380
+ .eq('session_id', session_id)\
381
+ .execute()
382
+
383
+ if response.data:
384
+ draft = response.data[0]
385
+ return {
386
+ 'situation': draft.get('situation', ''),
387
+ 'background': draft.get('background', ''),
388
+ 'assessment': draft.get('assessment', ''),
389
+ 'recommendation': draft.get('recommendation', ''),
390
+ }
391
+
392
+ return {
393
+ 'situation': '',
394
+ 'background': '',
395
+ 'assessment': '',
396
+ 'recommendation': '',
397
+ }
398
+
399
+ # ===== 톡계 κ΄€λ ¨ =====
400
+
401
+ def get_student_statistics(self, student_id: str) -> dict:
402
+ """
403
+ 학생별 톡계
404
+
405
+ Args:
406
+ student_id: 학생 ID
407
+
408
+ Returns:
409
+ 톡계 데이터
410
+ """
411
+ records = self.get_records_by_student(student_id)
412
+
413
+ if not records:
414
+ return {
415
+ 'total_submissions': 0,
416
+ 'average_score': 0,
417
+ 'highest_score': 0,
418
+ 'lowest_score': 0,
419
+ 'recent_submissions': [],
420
+ }
421
+
422
+ scores = [r['total_score'] for r in records if r.get('total_score') is not None]
423
+
424
+ return {
425
+ 'total_submissions': len(records),
426
+ 'average_score': sum(scores) / len(scores) if scores else 0,
427
+ 'highest_score': max(scores) if scores else 0,
428
+ 'lowest_score': min(scores) if scores else 0,
429
+ 'recent_submissions': [
430
+ {
431
+ 'id': r['id'],
432
+ 'scenario_id': r['scenario_id'],
433
+ 'score': r['total_score'],
434
+ 'submitted_at': r.get('submitted_at'),
435
+ }
436
+ for r in records[:5] # 졜근 5개
437
+ ],
438
+ }
439
+
440
+ def get_scenario_statistics(self, scenario_id: str) -> dict:
441
+ """
442
+ μ‹œλ‚˜λ¦¬μ˜€λ³„ 톡계
443
+
444
+ Args:
445
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
446
+
447
+ Returns:
448
+ 톡계 데이터
449
+ """
450
+ records = self.get_records_by_scenario(scenario_id)
451
+
452
+ if not records:
453
+ return {
454
+ 'total_attempts': 0,
455
+ 'average_score': 0,
456
+ 'score_distribution': {},
457
+ }
458
+
459
+ scores = [r['total_score'] for r in records if r.get('total_score') is not None]
460
+
461
+ # 점수 ꡬ간별 뢄포
462
+ distribution = {
463
+ 'excellent': len([s for s in scores if s >= 81]),
464
+ 'good': len([s for s in scores if 61 <= s < 81]),
465
+ 'needs_improvement': len([s for s in scores if s < 61]),
466
+ }
467
+
468
+ return {
469
+ 'total_attempts': len(records),
470
+ 'average_score': sum(scores) / len(scores) if scores else 0,
471
+ 'score_distribution': distribution,
472
+ }
473
+
474
+ # ===== μ‹€μ‹œκ°„ ꡬ독 (선택적) =====
475
+
476
+ def subscribe_to_chat(self, session_id: str, callback):
477
+ """
478
+ μ±„νŒ… λ©”μ‹œμ§€ μ‹€μ‹œκ°„ ꡬ독
479
+
480
+ Args:
481
+ session_id: μ„Έμ…˜ ID
482
+ callback: μƒˆ λ©”μ‹œμ§€ μˆ˜μ‹  μ‹œ ν˜ΈμΆœν•  콜백 ν•¨μˆ˜
483
+ """
484
+ channel = self.client.channel(f'chat_{session_id}')
485
+
486
+ channel.on_postgres_changes(
487
+ event='INSERT',
488
+ schema='public',
489
+ table='chat_history',
490
+ filter=f'session_id=eq.{session_id}',
491
+ callback=callback
492
+ ).subscribe()
493
+
494
+ return channel
495
+
496
+ # ===== μ‹œλ‚˜λ¦¬μ˜€ λ‘œλ“œ 및 μ„Έμ…˜ 관리 =====
497
+
498
+ def create_chat_session(self, student_id: str, scenario_id: str) -> str:
499
+ """
500
+ μƒˆ μ±„νŒ… μ„Έμ…˜ 생성 및 μ„Έμ…˜ ID λ°˜ν™˜ (TSID μ‚¬μš©)
501
+
502
+ Args:
503
+ student_id: 학생 ID
504
+ scenario_id: μ‹œλ‚˜λ¦¬μ˜€ ID
505
+
506
+ Returns:
507
+ session_id: TSID 기반 μ„Έμ…˜ ID
508
+ """
509
+ from tsidpy import TSID
510
+
511
+ # TSIDλ₯Ό μ‚¬μš©ν•˜μ—¬ μ‹œκ°„ μ •λ ¬ κ°€λŠ₯ν•œ 고유 ID 생성
512
+ tsid = TSID.create()
513
+ session_id = f"ses_{tsid.to_string()}"
514
+
515
+ # μ„Έμ…˜μ„ λ°μ΄ν„°λ² μ΄μŠ€μ— μ €μž₯
516
+ try:
517
+ session_data = {
518
+ 'session_id': session_id,
519
+ 'student_id': student_id,
520
+ 'scenario_id': scenario_id,
521
+ 'status': 'active',
522
+ 'started_at': 'now()'
523
+ }
524
+
525
+ # μ„Έμ…˜ ν…Œμ΄λΈ”μ΄ μžˆλ‹€λ©΄ μ €μž₯ (μ—†μœΌλ©΄ κ·Έλƒ₯ ID만 λ°˜ν™˜)
526
+ try:
527
+ self.client.table('sessions').insert(session_data).execute()
528
+ except Exception:
529
+ # sessions ν…Œμ΄λΈ”μ΄ 없을 경우 λ¬΄μ‹œ
530
+ pass
531
+
532
+ except Exception as e:
533
+ print(f"⚠️ μ„Έμ…˜ μ €μž₯ μ‹€νŒ¨ (ID만 μ‚¬μš©): {e}")
534
+
535
+ return session_id
536
+
537
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
538
+ """
539
+ μ„Έμ…˜ 쑰회
540
+
541
+ Args:
542
+ session_id: μ„Έμ…˜ ID
543
+
544
+ Returns:
545
+ μ„Έμ…˜ 데이터 λ˜λŠ” None
546
+ """
547
+ try:
548
+ response = self.client.table('sessions')\
549
+ .select('*')\
550
+ .eq('session_id', session_id)\
551
+ .execute()
552
+
553
+ return response.data[0] if response.data else None
554
+ except Exception:
555
+ return None
556
+
557
+ def update_session_status(self, session_id: str, status: str):
558
+ """
559
+ μ„Έμ…˜ μƒνƒœ μ—…λ°μ΄νŠΈ
560
+
561
+ Args:
562
+ session_id: μ„Έμ…˜ ID
563
+ status: μƒνƒœ ('active', 'completed', 'archived')
564
+ """
565
+ try:
566
+ # sessions ν…Œμ΄λΈ”μ΄ μžˆμ„ λ•Œλ§Œ μ—…λ°μ΄νŠΈ μ‹œλ„
567
+ self.client.table('sessions')\
568
+ .update({'status': status})\
569
+ .eq('session_id', session_id)\
570
+ .execute()
571
+ except Exception as e:
572
+ # sessions ν…Œμ΄λΈ”μ΄ μ—†λŠ” 경우 λ¬΄μ‹œ (선택적 κΈ°λŠ₯)
573
+ pass
574
+
575
+ def get_active_sessions_by_student(self, student_id: str) -> List[Dict[str, Any]]:
576
+ """
577
+ ν•™μƒμ˜ ν™œμ„± μ„Έμ…˜ λͺ©λ‘ 쑰회
578
+
579
+ Args:
580
+ student_id: 학생 ID
581
+
582
+ Returns:
583
+ μ„Έμ…˜ 데이터 리슀트
584
+ """
585
+ try:
586
+ response = self.client.table('sessions')\
587
+ .select('*')\
588
+ .eq('student_id', student_id)\
589
+ .eq('status', 'active')\
590
+ .order('started_at', desc=True)\
591
+ .execute()
592
+
593
+ return response.data or []
594
+ except Exception:
595
+ return []
596
+
597
+ def load_scenarios_from_json(self, json_file_path: str):
598
+ """
599
+ JSON νŒŒμΌμ—μ„œ ν™˜μž 및 μ‹œλ‚˜λ¦¬μ˜€ 데이터λ₯Ό 읽어 Supabase에 μ €μž₯
600
+
601
+ Args:
602
+ json_file_path: JSON 파일 경둜
603
+ """
604
+ import json
605
+ from datetime import datetime
606
+
607
+ with open(json_file_path, "r", encoding="utf-8") as f:
608
+ data = json.load(f)
609
+
610
+ # ν™˜μž 데이터 μ €μž₯
611
+ patient_data = data["patient"]
612
+
613
+ # admission_dateλ₯Ό λ¬Έμžμ—΄μ—μ„œ date 객체둜 λ³€ν™˜
614
+ if isinstance(patient_data.get("admission_date"), str):
615
+ patient_data["admission_date"] = datetime.strptime(
616
+ patient_data["admission_date"], "%Y-%m-%d"
617
+ ).date()
618
+
619
+ # κΈ°μ‘΄ ν™˜μžκ°€ μ—†μœΌλ©΄ 생성
620
+ existing_patient = self.get_patient(patient_data["id"])
621
+ if not existing_patient:
622
+ self.create_patient(patient_data)
623
+ print(f"βœ… ν™˜μž 생성: {patient_data['name']}")
624
+ else:
625
+ print(f"ℹ️ ν™˜μž 이미 쑴재: {patient_data['name']}")
626
+
627
+ # μ‹œλ‚˜λ¦¬μ˜€ 데이터 μ €μž₯
628
+ for scenario_data in data["scenarios"]:
629
+ existing_scenario = self.get_scenario(scenario_data["id"])
630
+ if not existing_scenario:
631
+ self.create_scenario(scenario_data)
632
+ print(f"βœ… μ‹œλ‚˜λ¦¬μ˜€ 생성: {scenario_data['title']}")
633
+ else:
634
+ print(f"ℹ️ μ‹œλ‚˜λ¦¬μ˜€ 이미 쑴재: {scenario_data['title']}")
635
+
636
+ def get_statistics_for_handoff(self, student_id: str) -> dict:
637
+ """
638
+ 학생별 톡계 (ν•˜μœ„ ν˜Έν™˜μ„± μœ μ§€)
639
+
640
+ Args:
641
+ student_id: 학생 ID
642
+
643
+ Returns:
644
+ 톡계 데이터
645
+ """
646
+ return self.get_student_statistics(student_id)
647
+
start.sh CHANGED
@@ -3,19 +3,33 @@
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 으둜 μ ‘μ†ν•˜μ„Έμš”."
 
3
  echo "πŸ₯ κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ μ‹œμž‘..."
4
  echo ""
5
 
6
+ # ν™˜κ²½ λ³€μˆ˜ 확인
7
+ if [ -f .env ]; then
8
+ export $(cat .env | grep -v '^#' | xargs)
9
+ fi
10
 
11
+ # 1. λ°μ΄ν„°λ² μ΄μŠ€ μ„€μ • 확인
12
+ if [ -n "$SUPABASE_DB_URL" ]; then
13
+ echo "πŸ”— Supabase λͺ¨λ“œλ‘œ μ‹€ν–‰ν•©λ‹ˆλ‹€..."
14
+ USE_LOCAL_POSTGRES=false
15
+ else
16
+ echo "πŸ”— 둜컬 PostgreSQL λͺ¨λ“œλ‘œ μ‹€ν–‰ν•©λ‹ˆλ‹€..."
17
+ USE_LOCAL_POSTGRES=true
18
+
19
+ # PostgreSQL μ‹œμž‘
20
+ echo "πŸ“¦ PostgreSQL μ»¨ν…Œμ΄λ„ˆ μ‹œμž‘ 쀑..."
21
+ docker-compose up -d
22
+
23
+ # PostgreSQL μ€€λΉ„ λŒ€κΈ°
24
+ echo "⏳ PostgreSQL μ€€λΉ„ λŒ€κΈ° 쀑..."
25
+ sleep 5
26
+ fi
27
 
28
+ # 2. λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™”
29
  echo "πŸ—„οΈ λ°μ΄ν„°λ² μ΄μŠ€ μ΄ˆκΈ°ν™” 쀑..."
30
  uv run init_db.py
31
 
32
+ # 3. Gradio μ•± μ‹€ν–‰
33
  echo ""
34
  echo "πŸš€ Gradio μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ‹œμž‘..."
35
  echo "λΈŒλΌμš°μ €μ—μ„œ http://localhost:7860 으둜 μ ‘μ†ν•˜μ„Έμš”."
supabase_init.sql ADDED
@@ -0,0 +1,331 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- =====================================================
2
+ -- Supabase 초기 μ„€μ • 톡합 슀크립트
3
+ -- κ°„ν˜Έ μΈμˆ˜μΈκ³„ ꡐ윑 ν”Œλž«νΌ
4
+ -- μ‹€ν–‰ μˆœμ„œ: 이 νŒŒμΌμ„ Supabase SQL Editorμ—μ„œ ν•œ 번만 μ‹€ν–‰ν•˜μ„Έμš”
5
+ -- =====================================================
6
+
7
+ -- =====================================================
8
+ -- 1. κΈ°λ³Έ ν…Œμ΄λΈ” 생성
9
+ -- =====================================================
10
+
11
+ -- 1.1 patients ν…Œμ΄λΈ” (ν™˜μž κΈ°λ³Έ 정보)
12
+ CREATE TABLE IF NOT EXISTS patients (
13
+ id VARCHAR(50) PRIMARY KEY,
14
+ name VARCHAR(100) NOT NULL,
15
+ age INTEGER NOT NULL,
16
+ gender VARCHAR(10) NOT NULL,
17
+ weight INTEGER,
18
+ height INTEGER,
19
+ diagnosis VARCHAR(200) NOT NULL,
20
+ admission_date DATE NOT NULL,
21
+ attending_physician VARCHAR(100),
22
+ allergies VARCHAR(200),
23
+ comorbidities TEXT,
24
+ room_number VARCHAR(20),
25
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
26
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
27
+ );
28
+
29
+ COMMENT ON TABLE patients IS 'ν™˜μž κΈ°λ³Έ 정보';
30
+ COMMENT ON COLUMN patients.id IS 'ν™˜μž ID (예: P001)';
31
+ COMMENT ON COLUMN patients.name IS 'ν™˜μž 이름';
32
+ COMMENT ON COLUMN patients.age IS 'λ‚˜μ΄';
33
+ COMMENT ON COLUMN patients.gender IS '성별 (남/μ—¬)';
34
+ COMMENT ON COLUMN patients.diagnosis IS '진단λͺ…';
35
+ COMMENT ON COLUMN patients.admission_date IS 'μž…μ›μΌ';
36
+ COMMENT ON COLUMN patients.allergies IS 'μ•Œλ ˆλ₯΄κΈ°';
37
+
38
+ -- 1.2 scenarios ν…Œμ΄λΈ” (μΈμˆ˜μΈκ³„ μ‹œλ‚˜λ¦¬μ˜€)
39
+ CREATE TABLE IF NOT EXISTS scenarios (
40
+ id VARCHAR(50) PRIMARY KEY,
41
+ patient_id VARCHAR(50) NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
42
+ title VARCHAR(200) NOT NULL,
43
+ day INTEGER NOT NULL,
44
+ handoff_situation VARCHAR(200),
45
+ vitals JSONB,
46
+ labs JSONB,
47
+ orders JSONB,
48
+ nursing_notes JSONB,
49
+ physical_exam JSONB,
50
+ imaging JSONB,
51
+ surgery_info JSONB,
52
+ discharge_education JSONB,
53
+ hospital_day INTEGER,
54
+ status_badge VARCHAR(20),
55
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
56
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
57
+ );
58
+
59
+ COMMENT ON TABLE scenarios IS 'μΈμˆ˜μΈκ³„ μ‹œλ‚˜λ¦¬μ˜€';
60
+ COMMENT ON COLUMN scenarios.id IS 'μ‹œλ‚˜λ¦¬μ˜€ ID (예: S001_D0_ER_WARD)';
61
+ COMMENT ON COLUMN scenarios.patient_id IS 'ν™˜μž ID (μ™Έλž˜ν‚€)';
62
+ COMMENT ON COLUMN scenarios.title IS 'μ‹œλ‚˜λ¦¬μ˜€ 제λͺ©';
63
+ COMMENT ON COLUMN scenarios.day IS 'μž¬μ› 일수 (0, 1, 2...)';
64
+ COMMENT ON COLUMN scenarios.handoff_situation IS 'μΈμˆ˜μΈκ³„ 상황 (예: 응급싀→병동)';
65
+
66
+ -- 1.3 handoff_records ν…Œμ΄λΈ” (μΈμˆ˜μΈκ³„ 기둝 및 평가)
67
+ -- λŒ€ν™” 기반 평가λ₯Ό κ³ λ €ν•˜μ—¬ NULL ν—ˆμš©
68
+ CREATE TABLE IF NOT EXISTS handoff_records (
69
+ id SERIAL PRIMARY KEY,
70
+ session_id VARCHAR(100),
71
+ student_id VARCHAR(50) NOT NULL,
72
+ scenario_id VARCHAR(50) NOT NULL REFERENCES scenarios(id) ON DELETE CASCADE,
73
+ submitted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
74
+ situation TEXT,
75
+ background TEXT,
76
+ assessment TEXT,
77
+ recommendation TEXT,
78
+ total_score FLOAT,
79
+ category_scores JSONB,
80
+ strengths JSONB,
81
+ improvements JSONB,
82
+ detailed_feedback TEXT,
83
+ -- λŒ€ν™” 기반 ν‰κ°€μš© 컬럼
84
+ missing_critical_info JSONB,
85
+ safety_concerns JSONB
86
+ );
87
+
88
+ COMMENT ON TABLE handoff_records IS 'ν•™μƒμ˜ μΈμˆ˜μΈκ³„ 기둝 및 AI 평가 κ²°κ³Ό';
89
+ COMMENT ON COLUMN handoff_records.session_id IS 'μ±„νŒ… μ„Έμ…˜ ID';
90
+ COMMENT ON COLUMN handoff_records.student_id IS '학생 μ‹λ³„μž';
91
+ COMMENT ON COLUMN handoff_records.scenario_id IS 'μ‹œλ‚˜λ¦¬μ˜€ ID (μ™Έλž˜ν‚€)';
92
+ COMMENT ON COLUMN handoff_records.missing_critical_info IS 'λˆ„λ½λœ μ€‘μš” 정보 λͺ©λ‘';
93
+ COMMENT ON COLUMN handoff_records.safety_concerns IS 'ν™˜μž μ•ˆμ „ κ΄€λ ¨ μ£Όμ˜μ‚¬ν•­ λͺ©λ‘';
94
+
95
+ -- 1.4 chat_history ν…Œμ΄λΈ” (학생과 AI κ°„μ˜ μ±„νŒ… 기둝)
96
+ CREATE TABLE IF NOT EXISTS chat_history (
97
+ id SERIAL PRIMARY KEY,
98
+ session_id VARCHAR(100) NOT NULL,
99
+ student_id VARCHAR(50) NOT NULL,
100
+ scenario_id VARCHAR(50) NOT NULL,
101
+ role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant')),
102
+ message TEXT NOT NULL,
103
+ timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW()
104
+ );
105
+
106
+ COMMENT ON TABLE chat_history IS '학생과 AI κ°„μ˜ μ±„νŒ… 기둝';
107
+
108
+ -- 1.5 sbar_drafts ν…Œμ΄λΈ” (μž‘μ„± 쀑인 SBAR μž„μ‹œ μ €μž₯)
109
+ CREATE TABLE IF NOT EXISTS sbar_drafts (
110
+ id SERIAL PRIMARY KEY,
111
+ session_id VARCHAR(100) NOT NULL UNIQUE,
112
+ student_id VARCHAR(50) NOT NULL,
113
+ scenario_id VARCHAR(50) NOT NULL,
114
+ situation TEXT DEFAULT '',
115
+ background TEXT DEFAULT '',
116
+ assessment TEXT DEFAULT '',
117
+ recommendation TEXT DEFAULT '',
118
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
119
+ );
120
+
121
+ COMMENT ON TABLE sbar_drafts IS 'μž‘μ„± 쀑인 SBAR μž„μ‹œ μ €μž₯';
122
+
123
+ -- 1.6 sessions ν…Œμ΄λΈ” (μ„Έμ…˜ 관리)
124
+ CREATE TABLE IF NOT EXISTS sessions (
125
+ id SERIAL PRIMARY KEY,
126
+ session_id VARCHAR(50) UNIQUE NOT NULL,
127
+ student_id VARCHAR(50) NOT NULL,
128
+ scenario_id VARCHAR(50) NOT NULL,
129
+ status VARCHAR(20) DEFAULT 'active',
130
+ started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
131
+ ended_at TIMESTAMP WITH TIME ZONE,
132
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
133
+ updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
134
+ );
135
+
136
+ COMMENT ON TABLE sessions IS 'TSID 기반 μ±„νŒ… μ„Έμ…˜ 관리 ν…Œμ΄λΈ”';
137
+ COMMENT ON COLUMN sessions.session_id IS 'TSID 기반 μ„Έμ…˜ μ‹λ³„μž (ses_XXXXX ν˜•μ‹)';
138
+ COMMENT ON COLUMN sessions.status IS 'μ„Έμ…˜ μƒνƒœ: active, completed, archived';
139
+
140
+ -- =====================================================
141
+ -- 2. 인덱슀 생성 (μ„±λŠ₯ μ΅œμ ν™”)
142
+ -- =====================================================
143
+
144
+ -- handoff_records 인덱슀
145
+ CREATE INDEX IF NOT EXISTS idx_handoff_records_student ON handoff_records(student_id);
146
+ CREATE INDEX IF NOT EXISTS idx_handoff_records_scenario ON handoff_records(scenario_id);
147
+ CREATE INDEX IF NOT EXISTS idx_handoff_records_session ON handoff_records(session_id);
148
+ CREATE INDEX IF NOT EXISTS idx_handoff_records_submitted ON handoff_records(submitted_at DESC);
149
+
150
+ -- chat_history 인덱슀
151
+ CREATE INDEX IF NOT EXISTS idx_chat_history_session ON chat_history(session_id);
152
+ CREATE INDEX IF NOT EXISTS idx_chat_history_student ON chat_history(student_id);
153
+ CREATE INDEX IF NOT EXISTS idx_chat_history_timestamp ON chat_history(timestamp);
154
+
155
+ -- sbar_drafts 인덱슀
156
+ CREATE INDEX IF NOT EXISTS idx_sbar_drafts_session ON sbar_drafts(session_id);
157
+ CREATE INDEX IF NOT EXISTS idx_sbar_drafts_student ON sbar_drafts(student_id);
158
+
159
+ -- scenarios 인덱슀
160
+ CREATE INDEX IF NOT EXISTS idx_scenarios_patient ON scenarios(patient_id);
161
+ CREATE INDEX IF NOT EXISTS idx_scenarios_day ON scenarios(day);
162
+
163
+ -- sessions 인덱슀
164
+ CREATE INDEX IF NOT EXISTS idx_sessions_student_id ON sessions(student_id);
165
+ CREATE INDEX IF NOT EXISTS idx_sessions_scenario_id ON sessions(scenario_id);
166
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
167
+ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC);
168
+
169
+ -- =====================================================
170
+ -- 3. 트리거 ν•¨μˆ˜ 및 트리거 생성
171
+ -- =====================================================
172
+
173
+ -- updated_at μžλ™ μ—…λ°μ΄νŠΈ ν•¨μˆ˜
174
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
175
+ RETURNS TRIGGER AS $$
176
+ BEGIN
177
+ NEW.updated_at = NOW();
178
+ RETURN NEW;
179
+ END;
180
+ $$ LANGUAGE plpgsql;
181
+
182
+ -- μ„Έμ…˜ μ’…λ£Œ μ‹œ ended_at μžλ™ μ—…λ°μ΄νŠΈ ν•¨μˆ˜
183
+ CREATE OR REPLACE FUNCTION update_session_ended_at()
184
+ RETURNS TRIGGER AS $$
185
+ BEGIN
186
+ IF NEW.status = 'completed' OR NEW.status = 'archived' THEN
187
+ IF NEW.ended_at IS NULL THEN
188
+ NEW.ended_at = NOW();
189
+ END IF;
190
+ END IF;
191
+ NEW.updated_at = NOW();
192
+ RETURN NEW;
193
+ END;
194
+ $$ LANGUAGE plpgsql;
195
+
196
+ -- 트리거 생성
197
+ CREATE TRIGGER update_patients_updated_at
198
+ BEFORE UPDATE ON patients
199
+ FOR EACH ROW
200
+ EXECUTE FUNCTION update_updated_at_column();
201
+
202
+ CREATE TRIGGER update_scenarios_updated_at
203
+ BEFORE UPDATE ON scenarios
204
+ FOR EACH ROW
205
+ EXECUTE FUNCTION update_updated_at_column();
206
+
207
+ CREATE TRIGGER update_sbar_drafts_updated_at
208
+ BEFORE UPDATE ON sbar_drafts
209
+ FOR EACH ROW
210
+ EXECUTE FUNCTION update_updated_at_column();
211
+
212
+ CREATE TRIGGER update_sessions_updated_at
213
+ BEFORE UPDATE ON sessions
214
+ FOR EACH ROW
215
+ EXECUTE FUNCTION update_updated_at_column();
216
+
217
+ CREATE TRIGGER update_session_on_status_change
218
+ BEFORE UPDATE ON sessions
219
+ FOR EACH ROW
220
+ WHEN (OLD.status IS DISTINCT FROM NEW.status)
221
+ EXECUTE FUNCTION update_session_ended_at();
222
+
223
+ -- =====================================================
224
+ -- 4. Row Level Security (RLS) μ„€μ •
225
+ -- =====================================================
226
+
227
+ -- RLS ν™œμ„±ν™”
228
+ ALTER TABLE patients ENABLE ROW LEVEL SECURITY;
229
+ ALTER TABLE scenarios ENABLE ROW LEVEL SECURITY;
230
+ ALTER TABLE handoff_records ENABLE ROW LEVEL SECURITY;
231
+ ALTER TABLE chat_history ENABLE ROW LEVEL SECURITY;
232
+ ALTER TABLE sbar_drafts ENABLE ROW LEVEL SECURITY;
233
+ ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
234
+
235
+ -- κΈ°μ‘΄ μ •μ±… μ‚­μ œ (쀑볡 λ°©μ§€)
236
+ DO $$
237
+ BEGIN
238
+ -- Chat History μ •μ±… μ‚­μ œ
239
+ DROP POLICY IF EXISTS "Allow all for chat_history" ON chat_history;
240
+ DROP POLICY IF EXISTS "Allow all operations for anon users on chat_history" ON chat_history;
241
+ DROP POLICY IF EXISTS "Allow all operations for authenticated users on chat_history" ON chat_history;
242
+ DROP POLICY IF EXISTS "Enable all access for chat_history" ON chat_history;
243
+ DROP POLICY IF EXISTS "chat_history_all_access" ON chat_history;
244
+
245
+ -- Handoff Records μ •μ±… μ‚­μ œ
246
+ DROP POLICY IF EXISTS "Allow all for handoff_records" ON handoff_records;
247
+ DROP POLICY IF EXISTS "Allow all operations for anon users on handoff_records" ON handoff_records;
248
+ DROP POLICY IF EXISTS "Allow all operations for authenticated users on handoff_records" ON handoff_records;
249
+ DROP POLICY IF EXISTS "Enable all access for handoff_records" ON handoff_records;
250
+ DROP POLICY IF EXISTS "handoff_records_all_access" ON handoff_records;
251
+
252
+ -- SBAR Drafts μ •μ±… μ‚­μ œ
253
+ DROP POLICY IF EXISTS "Allow all for sbar_drafts" ON sbar_drafts;
254
+ DROP POLICY IF EXISTS "Allow all operations for anon users on sbar_drafts" ON sbar_drafts;
255
+ DROP POLICY IF EXISTS "Allow all operations for authenticated users on sbar_drafts" ON sbar_drafts;
256
+ DROP POLICY IF EXISTS "Enable all access for sbar_drafts" ON sbar_drafts;
257
+ DROP POLICY IF EXISTS "sbar_drafts_all_access" ON sbar_drafts;
258
+
259
+ -- Patients μ •μ±… μ‚­μ œ
260
+ DROP POLICY IF EXISTS "Allow read access for all users" ON patients;
261
+ DROP POLICY IF EXISTS "Allow all for patients" ON patients;
262
+ DROP POLICY IF EXISTS "Allow all operations for anon users on patients" ON patients;
263
+ DROP POLICY IF EXISTS "Allow all operations for authenticated users on patients" ON patients;
264
+ DROP POLICY IF EXISTS "Enable all operations for anon users" ON patients;
265
+ DROP POLICY IF EXISTS "Enable all operations for authenticated users" ON patients;
266
+ DROP POLICY IF EXISTS "patients_all_access" ON patients;
267
+
268
+ -- Scenarios μ •μ±… μ‚­μ œ
269
+ DROP POLICY IF EXISTS "Allow read access for all users" ON scenarios;
270
+ DROP POLICY IF EXISTS "Allow all for scenarios" ON scenarios;
271
+ DROP POLICY IF EXISTS "Allow all operations for anon users on scenarios" ON scenarios;
272
+ DROP POLICY IF EXISTS "Allow all operations for authenticated users on scenarios" ON scenarios;
273
+ DROP POLICY IF EXISTS "Enable all operations for anon users" ON scenarios;
274
+ DROP POLICY IF EXISTS "Enable all operations for authenticated users" ON scenarios;
275
+ DROP POLICY IF EXISTS "scenarios_all_access" ON scenarios;
276
+
277
+ -- Sessions μ •μ±… μ‚­μ œ
278
+ DROP POLICY IF EXISTS "Enable all operations for anon users" ON sessions;
279
+ DROP POLICY IF EXISTS "Enable all operations for authenticated users" ON sessions;
280
+ DROP POLICY IF EXISTS "sessions_all_access" ON sessions;
281
+ END $$;
282
+
283
+ -- μƒˆ μ •μ±… 생성 (λͺ¨λ“  μ‚¬μš©μž μ ‘κ·Ό ν—ˆμš©)
284
+ CREATE POLICY "patients_all_access" ON patients
285
+ FOR ALL USING (true) WITH CHECK (true);
286
+
287
+ CREATE POLICY "scenarios_all_access" ON scenarios
288
+ FOR ALL USING (true) WITH CHECK (true);
289
+
290
+ CREATE POLICY "handoff_records_all_access" ON handoff_records
291
+ FOR ALL USING (true) WITH CHECK (true);
292
+
293
+ CREATE POLICY "chat_history_all_access" ON chat_history
294
+ FOR ALL USING (true) WITH CHECK (true);
295
+
296
+ CREATE POLICY "sbar_drafts_all_access" ON sbar_drafts
297
+ FOR ALL USING (true) WITH CHECK (true);
298
+
299
+ CREATE POLICY "sessions_all_access" ON sessions
300
+ FOR ALL USING (true) WITH CHECK (true);
301
+
302
+ -- =====================================================
303
+ -- μ™„λ£Œ λ©”μ‹œμ§€
304
+ -- =====================================================
305
+
306
+ DO $$
307
+ BEGIN
308
+ RAISE NOTICE '';
309
+ RAISE NOTICE '╔══════════════════════════════════════════════════════════════╗';
310
+ RAISE NOTICE 'β•‘ βœ… Supabase 초기 μ„€μ • μ™„λ£Œ! β•‘';
311
+ RAISE NOTICE 'β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•';
312
+ RAISE NOTICE '';
313
+ RAISE NOTICE 'πŸ“Š μƒμ„±λœ ν…Œμ΄λΈ”:';
314
+ RAISE NOTICE ' - patients (ν™˜μž 정보)';
315
+ RAISE NOTICE ' - scenarios (μ‹œλ‚˜λ¦¬μ˜€)';
316
+ RAISE NOTICE ' - handoff_records (μΈμˆ˜μΈκ³„ 기둝)';
317
+ RAISE NOTICE ' - chat_history (μ±„νŒ… 기둝)';
318
+ RAISE NOTICE ' - sbar_drafts (SBAR μ΄ˆμ•ˆ)';
319
+ RAISE NOTICE ' - sessions (μ„Έμ…˜ 관리)';
320
+ RAISE NOTICE '';
321
+ RAISE NOTICE 'πŸ”’ RLS μ •μ±…: λͺ¨λ“  ν…Œμ΄λΈ”μ— 전체 μ ‘κ·Ό ν—ˆμš©';
322
+ RAISE NOTICE 'πŸ“ˆ 인덱슀: μ„±λŠ₯ μ΅œμ ν™” μ™„λ£Œ';
323
+ RAISE NOTICE 'βš™οΈ 트리거: μžλ™ updated_at μ—…λ°μ΄νŠΈ 및 μ„Έμ…˜ μ’…λ£Œ 처리';
324
+ RAISE NOTICE '';
325
+ RAISE NOTICE 'πŸ“‹ λ‹€μŒ 단계:';
326
+ RAISE NOTICE ' 1. uv run scripts/init_db.py - μ‹œλ‚˜λ¦¬μ˜€ 데이터 λ‘œλ“œ';
327
+ RAISE NOTICE ' 2. uv run test_supabase_connection.py - μ—°κ²° ν…ŒμŠ€νŠΈ';
328
+ RAISE NOTICE ' 3. uv run app.py - μ•± μ‹€ν–‰';
329
+ RAISE NOTICE '';
330
+ END $$;
331
+