""" Supabase REST API 서비스 Supabase Python Client를 활용한 CRUD 작업 - Row Level Security (RLS) 자동 적용 - 실시간 구독 지원 - 자동 타입 변환 """ from datetime import date, datetime from typing import Any, Dict, List, Optional from config.database import supabase_client class SupabaseService: """Supabase REST API를 활용한 데이터베이스 서비스""" def __init__(self): if not supabase_client: raise ValueError("Supabase 클라이언트가 초기화되지 않았습니다. 환경 변수를 확인하세요.") self.client = supabase_client # ===== Patient 관련 ===== def create_patient(self, patient_data: dict) -> Dict[str, Any]: """ 환자 생성 Args: patient_data: 환자 정보 딕셔너리 Returns: 생성된 환자 데이터 """ # admission_date를 문자열로 변환 if isinstance(patient_data.get('admission_date'), date): patient_data['admission_date'] = patient_data['admission_date'].isoformat() response = self.client.table('patients').insert(patient_data).execute() return response.data[0] if response.data else None def get_patient(self, patient_id: str) -> Optional[Dict[str, Any]]: """ 환자 조회 Args: patient_id: 환자 ID Returns: 환자 데이터 또는 None """ response = self.client.table('patients')\ .select('*')\ .eq('id', patient_id)\ .execute() return response.data[0] if response.data else None def get_all_patients(self) -> List[Dict[str, Any]]: """ 모든 환자 조회 Returns: 환자 데이터 리스트 """ response = self.client.table('patients')\ .select('*')\ .order('created_at', desc=True)\ .execute() return response.data or [] def update_patient(self, patient_id: str, update_data: dict) -> Optional[Dict[str, Any]]: """ 환자 정보 업데이트 Args: patient_id: 환자 ID update_data: 업데이트할 데이터 Returns: 업데이트된 환자 데이터 """ response = self.client.table('patients')\ .update(update_data)\ .eq('id', patient_id)\ .execute() return response.data[0] if response.data else None # ===== Scenario 관련 ===== def create_scenario(self, scenario_data: dict) -> Dict[str, Any]: """ 시나리오 생성 Args: scenario_data: 시나리오 정보 딕셔너리 Returns: 생성된 시나리오 데이터 """ response = self.client.table('scenarios').insert(scenario_data).execute() return response.data[0] if response.data else None def get_scenario(self, scenario_id: str) -> Optional[Dict[str, Any]]: """ 시나리오 조회 Args: scenario_id: 시나리오 ID Returns: 시나리오 데이터 또는 None """ response = self.client.table('scenarios')\ .select('*')\ .eq('id', scenario_id)\ .execute() return response.data[0] if response.data else None def get_scenarios_by_patient(self, patient_id: str) -> List[Dict[str, Any]]: """ 특정 환자의 모든 시나리오 조회 Args: patient_id: 환자 ID Returns: 시나리오 데이터 리스트 """ response = self.client.table('scenarios')\ .select('*')\ .eq('patient_id', patient_id)\ .order('day')\ .execute() return response.data or [] def get_all_scenarios(self) -> List[Dict[str, Any]]: """ 모든 시나리오 조회 Returns: 시나리오 데이터 리스트 """ response = self.client.table('scenarios')\ .select('*')\ .order('created_at', desc=True)\ .execute() return response.data or [] # ===== HandoffRecord 관련 ===== def create_handoff_record( self, student_id: str, scenario_id: str, sbar_data: dict, evaluation: dict, session_id: Optional[str] = None ) -> Dict[str, Any]: """ 인수인계 기록 생성 Args: student_id: 학생 ID scenario_id: 시나리오 ID sbar_data: SBAR 데이터 evaluation: 평가 결과 session_id: 세션 ID (선택) Returns: 생성된 인수인계 기록 """ # 점수는 선택적 (대화 기반 평가에서는 NULL) record_data = { 'session_id': session_id, 'student_id': student_id, 'scenario_id': scenario_id, 'situation': sbar_data.get('situation', '') if sbar_data else '', 'background': sbar_data.get('background', '') if sbar_data else '', 'assessment': sbar_data.get('assessment', '') if sbar_data else '', 'recommendation': sbar_data.get('recommendation', '') if sbar_data else '', 'total_score': evaluation.get('total_score'), # NULL 허용 'category_scores': evaluation.get('category_scores'), # NULL 허용 'strengths': evaluation.get('strengths', []), 'improvements': evaluation.get('improvements', []), 'detailed_feedback': evaluation.get('detailed_feedback', ''), 'missing_critical_info': evaluation.get('missing_critical_info', []), # 새 필드 'safety_concerns': evaluation.get('safety_concerns', []), # 새 필드 } response = self.client.table('handoff_records').insert(record_data).execute() return response.data[0] if response.data else None def get_handoff_record(self, record_id: int) -> Optional[Dict[str, Any]]: """ 인수인계 기록 조회 Args: record_id: 기록 IDㅂ Returns: 인수인계 기록 또는 None """ response = self.client.table('handoff_records')\ .select('*')\ .eq('id', record_id)\ .execute() return response.data[0] if response.data else None def get_records_by_student(self, student_id: str) -> List[Dict[str, Any]]: """ 특정 학생의 모든 인수인계 기록 조회 Args: student_id: 학생 ID Returns: 인수인계 기록 리스트 """ response = self.client.table('handoff_records')\ .select('*')\ .eq('student_id', student_id)\ .order('submitted_at', desc=True)\ .execute() return response.data or [] def get_records_by_scenario(self, scenario_id: str) -> List[Dict[str, Any]]: """ 특정 시나리오의 모든 인수인계 기록 조회 Args: scenario_id: 시나리오 ID Returns: 인수인계 기록 리스트 """ response = self.client.table('handoff_records')\ .select('*')\ .eq('scenario_id', scenario_id)\ .order('submitted_at', desc=True)\ .execute() return response.data or [] # ===== ChatHistory 관련 ===== def save_chat_message( self, session_id: str, student_id: str, scenario_id: str, role: str, message: str ) -> Dict[str, Any]: """ 채팅 메시지 저장 Args: session_id: 세션 ID student_id: 학생 ID scenario_id: 시나리오 ID role: 역할 ('user' 또는 'assistant') message: 메시지 내용 Returns: 저장된 채팅 메시지 """ chat_data = { 'session_id': session_id, 'student_id': student_id, 'scenario_id': scenario_id, 'role': role, 'message': message, } response = self.client.table('chat_history').insert(chat_data).execute() return response.data[0] if response.data else None def get_chat_history(self, session_id: str) -> List[Dict[str, Any]]: """ 특정 세션의 채팅 기록 조회 Args: session_id: 세션 ID Returns: 채팅 메시지 리스트 """ response = self.client.table('chat_history')\ .select('*')\ .eq('session_id', session_id)\ .order('timestamp')\ .execute() return response.data or [] def get_chat_sessions_by_student(self, student_id: str) -> List[Dict[str, Any]]: """ 특정 학생의 모든 채팅 세션 조회 Args: student_id: 학생 ID Returns: 채팅 세션 리스트 """ response = self.client.table('chat_history')\ .select('session_id, scenario_id, timestamp')\ .eq('student_id', student_id)\ .order('timestamp', desc=True)\ .execute() # 중복 제거 (세션별로 그룹화) sessions = {} for chat in response.data or []: session_id = chat['session_id'] if session_id not in sessions: sessions[session_id] = chat return list(sessions.values()) # ===== SbarDraft 관련 ===== def save_draft_sbar( self, session_id: str, student_id: str, scenario_id: str, sbar_data: dict ) -> Dict[str, Any]: """ 작성 중인 SBAR 임시 저장 (Upsert) Args: session_id: 세션 ID student_id: 학생 ID scenario_id: 시나리오 ID sbar_data: SBAR 데이터 Returns: 저장된 SBAR 초안 """ draft_data = { 'session_id': session_id, 'student_id': student_id, 'scenario_id': scenario_id, 'situation': sbar_data.get('situation', ''), 'background': sbar_data.get('background', ''), 'assessment': sbar_data.get('assessment', ''), 'recommendation': sbar_data.get('recommendation', ''), } # Upsert: session_id가 있으면 업데이트, 없으면 삽입 response = self.client.table('sbar_drafts')\ .upsert(draft_data, on_conflict='session_id')\ .execute() return response.data[0] if response.data else None def get_draft_sbar(self, session_id: str) -> Dict[str, str]: """ 임시 저장된 SBAR 불러오기 Args: session_id: 세션 ID Returns: SBAR 데이터 딕셔너리 """ response = self.client.table('sbar_drafts')\ .select('*')\ .eq('session_id', session_id)\ .execute() if response.data: draft = response.data[0] return { 'situation': draft.get('situation', ''), 'background': draft.get('background', ''), 'assessment': draft.get('assessment', ''), 'recommendation': draft.get('recommendation', ''), } return { 'situation': '', 'background': '', 'assessment': '', 'recommendation': '', } # ===== 통계 관련 ===== def get_student_statistics(self, student_id: str) -> dict: """ 학생별 통계 Args: student_id: 학생 ID Returns: 통계 데이터 """ records = self.get_records_by_student(student_id) if not records: return { 'total_submissions': 0, 'average_score': 0, 'highest_score': 0, 'lowest_score': 0, 'recent_submissions': [], } scores = [r['total_score'] for r in records if r.get('total_score') is not None] return { 'total_submissions': len(records), 'average_score': sum(scores) / len(scores) if scores else 0, 'highest_score': max(scores) if scores else 0, 'lowest_score': min(scores) if scores else 0, 'recent_submissions': [ { 'id': r['id'], 'scenario_id': r['scenario_id'], 'score': r['total_score'], 'submitted_at': r.get('submitted_at'), } for r in records[:5] # 최근 5개 ], } def get_scenario_statistics(self, scenario_id: str) -> dict: """ 시나리오별 통계 Args: scenario_id: 시나리오 ID Returns: 통계 데이터 """ records = self.get_records_by_scenario(scenario_id) if not records: return { 'total_attempts': 0, 'average_score': 0, 'score_distribution': {}, } scores = [r['total_score'] for r in records if r.get('total_score') is not None] # 점수 구간별 분포 distribution = { 'excellent': len([s for s in scores if s >= 81]), 'good': len([s for s in scores if 61 <= s < 81]), 'needs_improvement': len([s for s in scores if s < 61]), } return { 'total_attempts': len(records), 'average_score': sum(scores) / len(scores) if scores else 0, 'score_distribution': distribution, } # ===== 실시간 구독 (선택적) ===== def subscribe_to_chat(self, session_id: str, callback): """ 채팅 메시지 실시간 구독 Args: session_id: 세션 ID callback: 새 메시지 수신 시 호출할 콜백 함수 """ channel = self.client.channel(f'chat_{session_id}') channel.on_postgres_changes( event='INSERT', schema='public', table='chat_history', filter=f'session_id=eq.{session_id}', callback=callback ).subscribe() return channel # ===== 시나리오 로드 및 세션 관리 ===== def create_chat_session(self, student_id: str, scenario_id: str) -> str: """ 새 채팅 세션 생성 및 세션 ID 반환 (TSID 사용) Args: student_id: 학생 ID scenario_id: 시나리오 ID Returns: session_id: TSID 기반 세션 ID """ from tsidpy import TSID # TSID를 사용하여 시간 정렬 가능한 고유 ID 생성 tsid = TSID.create() session_id = f"ses_{tsid.to_string()}" # 세션을 데이터베이스에 저장 try: session_data = { 'session_id': session_id, 'student_id': student_id, 'scenario_id': scenario_id, 'status': 'active' # started_at은 테이블 DEFAULT NOW()로 자동 설정됨 } print(f"📝 세션 생성 시도: session_id={session_id}, student_id={student_id}, scenario_id={scenario_id}") # 세션 테이블이 있다면 저장 (없으면 그냥 ID만 반환) try: result = self.client.table('sessions').insert(session_data).execute() print(f"✅ 세션 저장 성공: {result.data}") except Exception as insert_error: # sessions 테이블이 없을 경우 무시 print(f"⚠️ sessions 테이블 insert 실패 (계속 진행): {insert_error}") import traceback traceback.print_exc() except Exception as e: print(f"⚠️ 세션 저장 실패 (ID만 사용): {e}") import traceback traceback.print_exc() return session_id def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: """ 세션 조회 Args: session_id: 세션 ID Returns: 세션 데이터 또는 None """ try: response = self.client.table('sessions')\ .select('*')\ .eq('session_id', session_id)\ .execute() return response.data[0] if response.data else None except Exception: return None def update_session_status(self, session_id: str, status: str): """ 세션 상태 업데이트 Args: session_id: 세션 ID status: 상태 ('active', 'completed', 'archived') """ try: # sessions 테이블이 있을 때만 업데이트 시도 self.client.table('sessions')\ .update({'status': status})\ .eq('session_id', session_id)\ .execute() except Exception as e: # sessions 테이블이 없는 경우 무시 (선택적 기능) pass def get_active_sessions_by_student(self, student_id: str) -> List[Dict[str, Any]]: """ 학생의 활성 세션 목록 조회 Args: student_id: 학생 ID Returns: 세션 데이터 리스트 """ try: response = self.client.table('sessions')\ .select('*')\ .eq('student_id', student_id)\ .eq('status', 'active')\ .order('started_at', desc=True)\ .execute() return response.data or [] except Exception: return [] def load_scenarios_from_json(self, json_file_path: str): """ JSON 파일에서 환자 및 시나리오 데이터를 읽어 Supabase에 저장 Args: json_file_path: JSON 파일 경로 """ import json from datetime import datetime with open(json_file_path, "r", encoding="utf-8") as f: data = json.load(f) # 환자 데이터 저장 patient_data = data["patient"] # admission_date를 문자열에서 date 객체로 변환 if isinstance(patient_data.get("admission_date"), str): patient_data["admission_date"] = datetime.strptime( patient_data["admission_date"], "%Y-%m-%d" ).date() # 기존 환자가 없으면 생성 existing_patient = self.get_patient(patient_data["id"]) if not existing_patient: self.create_patient(patient_data) print(f"✅ 환자 생성: {patient_data['name']}") else: print(f"ℹ️ 환자 이미 존재: {patient_data['name']}") # 시나리오 데이터 저장 for scenario_data in data["scenarios"]: existing_scenario = self.get_scenario(scenario_data["id"]) if not existing_scenario: self.create_scenario(scenario_data) print(f"✅ 시나리오 생성: {scenario_data['title']}") else: print(f"ℹ️ 시나리오 이미 존재: {scenario_data['title']}") def get_statistics_for_handoff(self, student_id: str) -> dict: """ 학생별 통계 (하위 호환성 유지) Args: student_id: 학생 ID Returns: 통계 데이터 """ return self.get_student_statistics(student_id)