diff --git a/backend/app.py b/backend/app.py index 0443ae1..3bb7704 100644 --- a/backend/app.py +++ b/backend/app.py @@ -6805,6 +6805,65 @@ def api_animal_drug_info_preview(): return jsonify({'success': False, 'error': str(e)}), 500 +# ═══════════════════════════════════════════════════════════════════════════════ +# PAAI (Pharmacist Assistant AI) Admin 라우트 +# ═══════════════════════════════════════════════════════════════════════════════ + +@app.route('/admin/paai') +def admin_paai(): + """PAAI 분석 로그 관리 페이지""" + return render_template('admin_paai.html') + + +@app.route('/api/paai/logs') +def api_paai_logs(): + """PAAI 로그 목록 조회""" + from db.paai_logger import get_recent_logs + + limit = int(request.args.get('limit', 100)) + status = request.args.get('status', '') + has_severe = request.args.get('has_severe', '') + date = request.args.get('date', '') + + try: + logs = get_recent_logs( + limit=limit, + status=status if status else None, + has_severe=True if has_severe == 'true' else (False if has_severe == 'false' else None), + date=date if date else None + ) + return jsonify({'success': True, 'logs': logs, 'count': len(logs)}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/paai/logs/stats') +def api_paai_logs_stats(): + """PAAI 로그 통계""" + from db.paai_logger import get_stats + + try: + stats = get_stats() + return jsonify({'success': True, 'stats': stats}) + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@app.route('/api/paai/logs/') +def api_paai_log_detail(log_id): + """PAAI 로그 상세 조회""" + from db.paai_logger import get_log_detail + + try: + log = get_log_detail(log_id) + if log: + return jsonify({'success': True, 'log': log}) + else: + return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다.'}), 404 + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + if __name__ == '__main__': import os diff --git a/backend/db/paai_logger.py b/backend/db/paai_logger.py new file mode 100644 index 0000000..b1e5a1b --- /dev/null +++ b/backend/db/paai_logger.py @@ -0,0 +1,351 @@ +""" +PAAI (Pharmacist Assistant AI) 로깅 모듈 +- API 호출/응답 SQLite 저장 +- 분석 결과 및 피드백 관리 +""" +import sqlite3 +import json +import os +from datetime import datetime, timedelta +from pathlib import Path + +# DB 파일 경로 +DB_PATH = Path(__file__).parent / 'paai_logs.db' + + +def init_db(): + """DB 초기화 (테이블 생성)""" + schema_path = Path(__file__).parent / 'paai_logs_schema.sql' + + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + with open(schema_path, 'r', encoding='utf-8') as f: + schema = f.read() + cursor.executescript(schema) + + conn.commit() + conn.close() + print(f"PAAI 로그 DB 초기화 완료: {DB_PATH}") + + +def create_log( + pre_serial: str = None, + patient_code: str = None, + patient_name: str = None, + disease_code_1: str = None, + disease_name_1: str = None, + disease_code_2: str = None, + disease_name_2: str = None, + current_medications: list = None, + previous_serial: str = None, + previous_medications: list = None, + prescription_changes: dict = None, + otc_history: dict = None +) -> int: + """ + PAAI 분석 로그 생성 (초기 상태) + + Returns: + log_id: 생성된 로그 ID + """ + if not DB_PATH.exists(): + init_db() + + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + current_medications = current_medications or [] + previous_medications = previous_medications or [] + otc_history = otc_history or {} + + # 환자명 마스킹 + masked_name = None + if patient_name: + masked_name = patient_name[0] + '*' * (len(patient_name) - 1) if len(patient_name) > 1 else patient_name + + cursor.execute(""" + INSERT INTO paai_logs ( + pre_serial, patient_code, patient_name, + disease_code_1, disease_name_1, disease_code_2, disease_name_2, + current_medications, current_med_count, + previous_serial, previous_medications, prescription_changes, + otc_history, otc_visit_count, + status + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending') + """, ( + pre_serial, + patient_code, + masked_name, + disease_code_1, + disease_name_1, + disease_code_2, + disease_name_2, + json.dumps(current_medications, ensure_ascii=False), + len(current_medications), + previous_serial, + json.dumps(previous_medications, ensure_ascii=False), + json.dumps(prescription_changes, ensure_ascii=False) if prescription_changes else None, + json.dumps(otc_history, ensure_ascii=False), + otc_history.get('visit_count', 0) + )) + + log_id = cursor.lastrowid + conn.commit() + conn.close() + + return log_id + + +def update_kims_result( + log_id: int, + kims_drug_codes: list = None, + kims_interactions: list = None, + kims_response_time_ms: int = 0 +): + """KIMS 상호작용 결과 업데이트""" + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + kims_drug_codes = kims_drug_codes or [] + kims_interactions = kims_interactions or [] + + # 심각한 상호작용 여부 (severity 1 또는 2) + has_severe = any( + str(i.get('severity', '5')) in ['1', '2'] + for i in kims_interactions + ) + + cursor.execute(""" + UPDATE paai_logs SET + kims_drug_codes = ?, + kims_drug_count = ?, + kims_interactions = ?, + kims_interaction_count = ?, + kims_has_severe = ?, + kims_response_time_ms = ?, + status = 'kims_done' + WHERE id = ? + """, ( + json.dumps(kims_drug_codes, ensure_ascii=False), + len(kims_drug_codes), + json.dumps(kims_interactions, ensure_ascii=False), + len(kims_interactions), + 1 if has_severe else 0, + kims_response_time_ms, + log_id + )) + + conn.commit() + conn.close() + + +def update_ai_result( + log_id: int, + ai_prompt: str = None, + ai_model: str = None, + ai_response: dict = None, + ai_response_time_ms: int = 0, + ai_token_count: int = None +): + """AI 분석 결과 업데이트""" + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + cursor.execute(""" + UPDATE paai_logs SET + ai_prompt = ?, + ai_model = ?, + ai_response = ?, + ai_response_time_ms = ?, + ai_token_count = ?, + status = 'success' + WHERE id = ? + """, ( + ai_prompt, + ai_model, + json.dumps(ai_response, ensure_ascii=False) if ai_response else None, + ai_response_time_ms, + ai_token_count, + log_id + )) + + conn.commit() + conn.close() + + +def update_error(log_id: int, error_message: str): + """에러 상태 업데이트""" + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + cursor.execute(""" + UPDATE paai_logs SET + status = 'error', + error_message = ? + WHERE id = ? + """, (error_message, log_id)) + + conn.commit() + conn.close() + + +def update_feedback(log_id: int, useful: bool, comment: str = None): + """피드백 업데이트""" + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + cursor.execute(""" + UPDATE paai_logs SET + feedback_useful = ?, + feedback_comment = ? + WHERE id = ? + """, (1 if useful else 0, comment, log_id)) + + conn.commit() + conn.close() + + +def get_recent_logs( + limit: int = 100, + status: str = None, + has_severe: bool = None, + date: str = None +) -> list: + """최근 로그 조회""" + if not DB_PATH.exists(): + return [] + + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + query = "SELECT * FROM paai_logs WHERE 1=1" + params = [] + + if status: + query += " AND status = ?" + params.append(status) + + if has_severe is not None: + query += " AND kims_has_severe = ?" + params.append(1 if has_severe else 0) + + if date: + query += " AND DATE(created_at) = ?" + params.append(date) + + query += " ORDER BY created_at DESC LIMIT ?" + params.append(limit) + + cursor.execute(query, params) + rows = cursor.fetchall() + + result = [] + for row in rows: + log = dict(row) + # JSON 필드 파싱 + for field in ['current_medications', 'previous_medications', 'prescription_changes', + 'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']: + if log.get(field): + try: + log[field] = json.loads(log[field]) + except: + pass + result.append(log) + + conn.close() + return result + + +def get_log_detail(log_id: int) -> dict: + """로그 상세 조회""" + if not DB_PATH.exists(): + return None + + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + cursor.execute("SELECT * FROM paai_logs WHERE id = ?", (log_id,)) + row = cursor.fetchone() + + if not row: + conn.close() + return None + + log = dict(row) + + # JSON 필드 파싱 + for field in ['current_medications', 'previous_medications', 'prescription_changes', + 'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']: + if log.get(field): + try: + log[field] = json.loads(log[field]) + except: + pass + + conn.close() + return log + + +def get_stats() -> dict: + """통계 조회""" + if not DB_PATH.exists(): + return { + 'total': 0, + 'today': 0, + 'success_rate': 0, + 'avg_response_time': 0, + 'severe_count': 0 + } + + conn = sqlite3.connect(str(DB_PATH)) + cursor = conn.cursor() + + today = datetime.now().strftime('%Y-%m-%d') + + # 전체 건수 + cursor.execute("SELECT COUNT(*) FROM paai_logs") + total = cursor.fetchone()[0] + + # 오늘 건수 + cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE DATE(created_at) = ?", (today,)) + today_count = cursor.fetchone()[0] + + # 성공률 + cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE status = 'success'") + success_count = cursor.fetchone()[0] + success_rate = (success_count / total * 100) if total > 0 else 0 + + # 평균 응답시간 + cursor.execute("SELECT AVG(ai_response_time_ms) FROM paai_logs WHERE ai_response_time_ms > 0") + avg_time = cursor.fetchone()[0] or 0 + + # 심각한 상호작용 건수 (오늘) + cursor.execute(""" + SELECT COUNT(*) FROM paai_logs + WHERE DATE(created_at) = ? AND kims_has_severe = 1 + """, (today,)) + severe_count = cursor.fetchone()[0] + + # 피드백 통계 + cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful = 1") + useful_count = cursor.fetchone()[0] + cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful IS NOT NULL") + feedback_total = cursor.fetchone()[0] + + conn.close() + + return { + 'total': total, + 'today': today_count, + 'success_rate': round(success_rate, 1), + 'avg_response_time': int(avg_time), + 'severe_count': severe_count, + 'feedback': { + 'useful': useful_count, + 'total': feedback_total, + 'rate': round(useful_count / feedback_total * 100, 1) if feedback_total > 0 else 0 + } + } diff --git a/backend/db/paai_logs_schema.sql b/backend/db/paai_logs_schema.sql new file mode 100644 index 0000000..65277aa --- /dev/null +++ b/backend/db/paai_logs_schema.sql @@ -0,0 +1,59 @@ +-- PAAI (Pharmacist Assistant AI) 로그 스키마 +-- 생성일: 2026-03-04 + +CREATE TABLE IF NOT EXISTS paai_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- 요청 정보 + pre_serial TEXT, -- 처방번호 + patient_code TEXT, -- 환자코드 (CusCode) + patient_name TEXT, -- 환자명 (마스킹: 김**) + + -- 질병 정보 + disease_code_1 TEXT, -- St1 (상병코드1) + disease_name_1 TEXT, -- 상병명1 + disease_code_2 TEXT, -- St2 (상병코드2) + disease_name_2 TEXT, -- 상병명2 + + -- 처방 정보 + current_medications TEXT, -- JSON: 현재 처방 [{code, name, dosage, ...}] + current_med_count INTEGER, -- 현재 처방 약품 수 + previous_serial TEXT, -- 이전 처방번호 + previous_medications TEXT, -- JSON: 이전 처방 + prescription_changes TEXT, -- JSON: {added, removed, changed} + + -- OTC 이력 + otc_history TEXT, -- JSON: {purchases, frequent_items} + otc_visit_count INTEGER, -- OTC 구매 횟수 + + -- KIMS 상호작용 + kims_drug_codes TEXT, -- JSON: 검사한 KD코드 배열 + kims_drug_count INTEGER, -- 검사한 약품 수 + kims_interactions TEXT, -- JSON: 상호작용 결과 + kims_interaction_count INTEGER, -- 상호작용 건수 + kims_has_severe BOOLEAN DEFAULT 0, -- 심각한 상호작용 (severity 1,2) + kims_response_time_ms INTEGER, -- KIMS API 응답시간 + + -- AI 분석 + ai_prompt TEXT, -- AI에 전달한 프롬프트 + ai_model TEXT, -- 사용된 모델 + ai_response TEXT, -- JSON: AI 분석 결과 + ai_response_time_ms INTEGER, -- AI 응답 시간 + ai_token_count INTEGER, -- 토큰 사용량 + + -- 상태 + status TEXT DEFAULT 'pending', -- pending, kims_done, success, error + error_message TEXT, + + -- 피드백 + feedback_useful INTEGER, -- 1=유용, 0=아님, NULL=미응답 + feedback_comment TEXT -- 약사 코멘트 +); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS idx_paai_created ON paai_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_paai_patient ON paai_logs(patient_code); +CREATE INDEX IF NOT EXISTS idx_paai_status ON paai_logs(status); +CREATE INDEX IF NOT EXISTS idx_paai_serial ON paai_logs(pre_serial); +CREATE INDEX IF NOT EXISTS idx_paai_severe ON paai_logs(kims_has_severe); diff --git a/backend/pmr_api.py b/backend/pmr_api.py index 63c26f9..302aead 100644 --- a/backend/pmr_api.py +++ b/backend/pmr_api.py @@ -816,3 +816,380 @@ def get_patient_otc_history(cus_code): except Exception as e: logging.error(f"환자 OTC 구매 이력 조회 오류: {e}") return jsonify({'success': False, 'error': str(e)}), 500 + + +# ───────────────────────────────────────────────────────────── +# PAAI (Pharmacist Assistant AI) API +# ───────────────────────────────────────────────────────────── + +@pmr_bp.route('/api/paai/analyze', methods=['POST']) +def paai_analyze(): + """ + PAAI 분석 API + + Request: + { + "pre_serial": "P20260304001", + "cus_code": "00001234", + "patient_name": "홍길동", + "disease_info": { + "code_1": "M170", "name_1": "무릎골관절염", + "code_2": "K299", "name_2": "상세불명의 위십이지장염" + }, + "current_medications": [ + {"code": "055101150", "name": "록소프로펜정", "dosage": 1, "frequency": 3, "days": 7} + ], + "previous_serial": "P20260225001", + "previous_medications": [...], + "otc_history": {...} + } + """ + import requests as http_requests + import time as time_module + from db.paai_logger import create_log, update_kims_result, update_ai_result, update_error + + start_time = time_module.time() + log_id = None + + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400 + + pre_serial = data.get('pre_serial') + cus_code = data.get('cus_code') + patient_name = data.get('patient_name') + disease_info = data.get('disease_info', {}) + current_medications = data.get('current_medications', []) + previous_serial = data.get('previous_serial') + previous_medications = data.get('previous_medications', []) + otc_history = data.get('otc_history', {}) + + # 처방 변화 분석 + prescription_changes = analyze_prescription_changes( + current_medications, previous_medications + ) + + # 1. 로그 생성 + log_id = create_log( + pre_serial=pre_serial, + patient_code=cus_code, + patient_name=patient_name, + disease_code_1=disease_info.get('code_1'), + disease_name_1=disease_info.get('name_1'), + disease_code_2=disease_info.get('code_2'), + disease_name_2=disease_info.get('name_2'), + current_medications=current_medications, + previous_serial=previous_serial, + previous_medications=previous_medications, + prescription_changes=prescription_changes, + otc_history=otc_history + ) + + # 2. KD코드 추출 (9자리 KIMS 코드) + kd_codes = [] + drug_names = [] + for med in current_medications: + code = med.get('code', '') + if code and len(str(code)) == 9: + kd_codes.append(str(code)) + drug_names.append(med.get('name', '')) + + # 3. KIMS API 호출 (약품 2개 이상인 경우) + kims_interactions = [] + kims_start = time_module.time() + + if len(kd_codes) >= 2: + try: + kims_url = "https://api2.kims.co.kr/api/interaction/info" + kims_headers = { + 'Authorization': 'Basic VFNQTUtSOg==', + 'Content-Type': 'application/json', + 'Accept': 'application/json; charset=utf-8' + } + kims_payload = {'KDCodes': kd_codes} + + kims_response = http_requests.get( + kims_url, + headers=kims_headers, + data=__import__('json').dumps(kims_payload), + timeout=10, + verify=False + ) + + if kims_response.status_code == 200: + kims_data = kims_response.json() + if kims_data.get('Message') == 'SUCCESS': + kims_interactions = kims_data.get('InteractionList', []) + except Exception as kims_err: + logging.warning(f"KIMS API 오류 (무시하고 계속): {kims_err}") + + kims_time = int((time_module.time() - kims_start) * 1000) + + # KIMS 결과 로그 업데이트 + update_kims_result( + log_id=log_id, + kims_drug_codes=kd_codes, + kims_interactions=kims_interactions, + kims_response_time_ms=kims_time + ) + + # 4. AI 프롬프트 생성 + ai_prompt = build_paai_prompt( + disease_info=disease_info, + current_medications=current_medications, + prescription_changes=prescription_changes, + kims_interactions=kims_interactions, + otc_history=otc_history + ) + + # 5. Clawdbot AI 호출 (WebSocket) + ai_start = time_module.time() + ai_response = call_clawdbot_ai(ai_prompt) + ai_time = int((time_module.time() - ai_start) * 1000) + + # AI 결과 로그 업데이트 + update_ai_result( + log_id=log_id, + ai_prompt=ai_prompt, + ai_model='claude-sonnet-4', + ai_response=ai_response, + ai_response_time_ms=ai_time + ) + + total_time = int((time_module.time() - start_time) * 1000) + + return jsonify({ + 'success': True, + 'log_id': log_id, + 'analysis': ai_response, + 'kims_summary': { + 'drug_count': len(kd_codes), + 'interaction_count': len(kims_interactions), + 'has_severe': any(str(i.get('Severity', '5')) in ['1', '2'] for i in kims_interactions) + }, + 'timing': { + 'kims_ms': kims_time, + 'ai_ms': ai_time, + 'total_ms': total_time + } + }) + + except Exception as e: + logging.error(f"PAAI 분석 오류: {e}") + if log_id: + update_error(log_id, str(e)) + return jsonify({'success': False, 'error': str(e)}), 500 + + +def analyze_prescription_changes(current: list, previous: list) -> dict: + """처방 변화 분석""" + current_codes = {m.get('code'): m for m in current} + previous_codes = {m.get('code'): m for m in previous} + + added = [] + removed = [] + changed = [] + same = [] + + # 추가된 약품 + for code, med in current_codes.items(): + if code not in previous_codes: + added.append(med) + else: + # 변경 여부 확인 + prev_med = previous_codes[code] + changes = [] + for field in ['dosage', 'frequency', 'days']: + if med.get(field) != prev_med.get(field): + changes.append({ + 'field': field, + 'from': prev_med.get(field), + 'to': med.get(field) + }) + if changes: + changed.append({'medication': med, 'changes': changes}) + else: + same.append(med) + + # 중단된 약품 + for code, med in previous_codes.items(): + if code not in current_codes: + removed.append(med) + + return { + 'added': added, + 'removed': removed, + 'changed': changed, + 'same': same + } + + +def build_paai_prompt( + disease_info: dict, + current_medications: list, + prescription_changes: dict, + kims_interactions: list, + otc_history: dict +) -> str: + """AI 프롬프트 생성""" + + # 질병 정보 + diseases = [] + if disease_info.get('code_1'): + diseases.append(f"[{disease_info['code_1']}] {disease_info.get('name_1', '')}") + if disease_info.get('code_2'): + diseases.append(f"[{disease_info['code_2']}] {disease_info.get('name_2', '')}") + + # 현재 처방 + med_lines = [] + for med in current_medications: + line = f"- {med.get('name', '?')}: {med.get('dosage', 0)}정 × {med.get('frequency', 0)}회 × {med.get('days', 0)}일" + med_lines.append(line) + + # 처방 변화 + change_lines = [] + if prescription_changes.get('added'): + names = [m.get('name', '?') for m in prescription_changes['added']] + change_lines.append(f"- 추가: {', '.join(names)}") + if prescription_changes.get('removed'): + names = [m.get('name', '?') for m in prescription_changes['removed']] + change_lines.append(f"- 중단: {', '.join(names)}") + if prescription_changes.get('changed'): + for item in prescription_changes['changed']: + med = item['medication'] + changes = item['changes'] + change_desc = ', '.join([f"{c['field']}: {c['from']}→{c['to']}" for c in changes]) + change_lines.append(f"- 변경: {med.get('name', '?')} ({change_desc})") + + # KIMS 상호작용 + kims_lines = [] + for inter in kims_interactions: + severity = inter.get('Severity', 5) + severity_text = {1: '🔴 금기', 2: '🟠 경고', 3: '🟡 주의', 4: '참고', 5: '정보'}.get(int(severity), '정보') + drug1 = inter.get('Drug1Name', '?') + drug2 = inter.get('Drug2Name', '?') + desc = inter.get('InteractionDesc', '')[:100] + kims_lines.append(f"- [{severity_text}] {drug1} + {drug2}: {desc}") + + # OTC 이력 + otc_lines = [] + if otc_history.get('frequent_items'): + for item in otc_history['frequent_items'][:5]: + otc_lines.append(f"- {item.get('name', '?')} ({item.get('count', 0)}회 구매)") + + prompt = f"""당신은 약사를 보조하는 AI입니다. 환자 정보와 KIMS 상호작용 데이터를 바탕으로 분석해주세요. + +## 환자 질병 +{chr(10).join(diseases) if diseases else '- 정보 없음'} + +## 현재 처방 +{chr(10).join(med_lines) if med_lines else '- 정보 없음'} + +## 처방 변화 (vs 이전) +{chr(10).join(change_lines) if change_lines else '- 변화 없음'} + +## KIMS 약물 상호작용 +{chr(10).join(kims_lines) if kims_lines else '- 상호작용 없음'} + +## OTC 구매 이력 +{chr(10).join(otc_lines) if otc_lines else '- 이력 없음'} + +## 요청 +다음 형식의 JSON으로 간결하게 답변해주세요: +{{ + "prescription_insight": "처방 변화에 대한 분석 (1-2문장)", + "kims_analysis": "KIMS 상호작용 해석 및 임상적 의미 (1-2문장)", + "cautions": ["복용 주의사항 1", "복용 주의사항 2"], + "otc_recommendations": [ + {{"product": "추천 OTC명", "reason": "추천 이유 (현재 처방과 상호작용 없음 확인)"}} + ], + "counseling_points": ["상담 포인트 1", "상담 포인트 2"] +}} +""" + return prompt + + +def call_clawdbot_ai(prompt: str) -> dict: + """Clawdbot AI 호출 (HTTP API)""" + import requests as http_requests + import json + + try: + # Clawdbot Gateway API 호출 + response = http_requests.post( + 'http://localhost:8765/api/chat', + json={ + 'message': prompt, + 'session': 'paai-analysis', + 'timeout': 60 + }, + timeout=65 + ) + + if response.status_code == 200: + result = response.json() + # AI 응답에서 JSON 파싱 시도 + ai_text = result.get('response', '') + + # JSON 블록 추출 + try: + import re + json_match = re.search(r'\{[\s\S]*\}', ai_text) + if json_match: + return json.loads(json_match.group()) + except: + pass + + # JSON 파싱 실패 시 텍스트 그대로 반환 + return { + 'prescription_insight': ai_text[:200] if ai_text else '분석 결과 없음', + 'kims_analysis': '', + 'cautions': [], + 'otc_recommendations': [], + 'counseling_points': [] + } + else: + raise Exception(f"Clawdbot API 오류: {response.status_code}") + + except http_requests.exceptions.ConnectionError: + # Clawdbot 연결 안됨 - 기본 응답 생성 + return generate_fallback_response(prompt) + except Exception as e: + logging.error(f"Clawdbot AI 호출 오류: {e}") + return generate_fallback_response(prompt) + + +def generate_fallback_response(prompt: str) -> dict: + """Clawdbot 연결 실패 시 기본 응답""" + return { + 'prescription_insight': 'AI 분석 서비스에 연결할 수 없습니다. KIMS 상호작용 정보를 직접 확인해주세요.', + 'kims_analysis': '', + 'cautions': ['AI 분석 불가 - 직접 검토 필요'], + 'otc_recommendations': [], + 'counseling_points': [], + '_fallback': True + } + + +@pmr_bp.route('/api/paai/feedback', methods=['POST']) +def paai_feedback(): + """PAAI 피드백 저장""" + from db.paai_logger import update_feedback + + try: + data = request.get_json() + log_id = data.get('log_id') + useful = data.get('useful') + comment = data.get('comment') + + if not log_id: + return jsonify({'success': False, 'error': 'log_id 필요'}), 400 + + update_feedback(log_id, useful, comment) + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"PAAI 피드백 저장 오류: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/backend/templates/admin_paai.html b/backend/templates/admin_paai.html new file mode 100644 index 0000000..fab206c --- /dev/null +++ b/backend/templates/admin_paai.html @@ -0,0 +1,494 @@ + + + + + + PAAI 분석 로그 - 관리자 + + + +
+

🤖 PAAI 분석 로그

+ ← 관리자 홈 +
+ +
+ +
+
+
-
+
전체
+
+
+
-
+
오늘
+
+
+
-
+
성공률
+
+
+
-
+
평균 응답(ms)
+
+
+
-
+
심각 상호작용
+
+
+
-
+
유용 피드백
+
+
+ + +
+ + + + + +
+ + +
+ + + + + + + + + + + + + + + + +
시간환자처방번호약품수KIMS상태응답시간피드백
로딩 중...
+
+
+ + + + + + + \ No newline at end of file diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index c82b387..fc69b80 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.html @@ -161,6 +161,167 @@ transform: scale(1.05); } + /* PAAI 버튼 */ + .detail-header .rx-info .paai-badge { + background: linear-gradient(135deg, #10b981, #059669) !important; + color: #fff !important; + cursor: pointer; + transition: all 0.2s; + font-weight: 600; + } + .detail-header .rx-info .paai-badge:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); + } + + /* PAAI 모달 */ + .paai-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 1100; + justify-content: center; + align-items: center; + padding: 20px; + } + .paai-modal.show { display: flex; } + .paai-modal-content { + background: #fff; + border-radius: 16px; + width: 100%; + max-width: 700px; + max-height: 85vh; + overflow: hidden; + box-shadow: 0 25px 50px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + } + .paai-modal-header { + background: linear-gradient(135deg, #10b981, #059669); + color: #fff; + padding: 20px 25px; + display: flex; + justify-content: space-between; + align-items: center; + } + .paai-modal-header h3 { font-size: 1.3rem; } + .paai-modal-close { + background: rgba(255,255,255,0.2); + border: none; + color: #fff; + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 1.5rem; + cursor: pointer; + transition: background 0.2s; + } + .paai-modal-close:hover { background: rgba(255,255,255,0.3); } + .paai-modal-body { + padding: 25px; + overflow-y: auto; + flex: 1; + } + .paai-loading { + text-align: center; + padding: 60px 20px; + } + .paai-loading .spinner { + width: 50px; + height: 50px; + border: 4px solid #e5e7eb; + border-top-color: #10b981; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px; + } + .paai-section { + margin-bottom: 24px; + } + .paai-section-title { + font-size: 1rem; + font-weight: 700; + color: #374151; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; + } + .paai-section-content { + background: #f9fafb; + padding: 15px; + border-radius: 10px; + font-size: 0.95rem; + line-height: 1.6; + color: #4b5563; + } + .paai-list { + list-style: none; + padding: 0; + margin: 0; + } + .paai-list li { + padding: 8px 0; + border-bottom: 1px solid #e5e7eb; + display: flex; + align-items: flex-start; + gap: 10px; + } + .paai-list li:last-child { border-bottom: none; } + .paai-list li::before { + content: '•'; + color: #10b981; + font-weight: bold; + } + .paai-caution { + background: #fef3c7 !important; + border-left: 4px solid #f59e0b; + } + .paai-otc-rec { + background: #dbeafe !important; + padding: 12px 15px; + border-radius: 8px; + margin-bottom: 10px; + } + .paai-otc-rec .product { font-weight: 600; color: #1e40af; } + .paai-otc-rec .reason { font-size: 0.9rem; color: #64748b; margin-top: 4px; } + .paai-kims-severe { + background: #fee2e2 !important; + border-left: 4px solid #ef4444; + } + .paai-modal-footer { + padding: 15px 25px; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + align-items: center; + } + .paai-feedback { + display: flex; + gap: 10px; + align-items: center; + } + .paai-feedback span { font-size: 0.9rem; color: #6b7280; } + .paai-feedback button { + padding: 8px 16px; + border: 2px solid #e5e7eb; + border-radius: 8px; + background: #fff; + cursor: pointer; + font-size: 1rem; + transition: all 0.2s; + } + .paai-feedback button:hover { border-color: #10b981; } + .paai-feedback button.selected { background: #d1fae5; border-color: #10b981; } + .paai-timing { + font-size: 0.8rem; + color: #9ca3af; + } + /* OTC 모달 */ .otc-modal { display: none; @@ -592,6 +753,31 @@ + +
+
+
+

🤖 PAAI 분석 결과

+ +
+
+
+
+
AI 분석 중...
+
KIMS 상호작용 확인 + AI 분석
+
+
+ +
+
+
@@ -1096,9 +1282,14 @@ } else { otcData = null; } + + // PAAI 버튼 추가 (항상 표시) + addPaaiButton(); } catch (err) { console.error('OTC check error:', err); otcData = null; + // OTC 오류여도 PAAI 버튼은 추가 + addPaaiButton(); } } @@ -1171,6 +1362,230 @@ document.getElementById('otcModal').style.display = 'none'; } + // ───────────────────────────────────────────────────────────── + // PAAI (Pharmacist Assistant AI) 함수들 + // ───────────────────────────────────────────────────────────── + + let currentPaaiLogId = null; + + function addPaaiButton() { + const rxInfo = document.getElementById('rxInfo'); + if (!rxInfo || rxInfo.querySelector('.paai-badge')) return; + + const paaiBtn = document.createElement('span'); + paaiBtn.className = 'paai-badge'; + paaiBtn.textContent = '🤖 PAAI 분석'; + paaiBtn.onclick = showPaaiModal; + rxInfo.appendChild(paaiBtn); + } + + async function showPaaiModal() { + if (!currentPrescription) return; + + const modal = document.getElementById('paaiModal'); + const body = document.getElementById('paaiBody'); + const footer = document.getElementById('paaiFooter'); + + // 초기화 + body.innerHTML = ` +
+
+
AI 분석 중...
+
KIMS 상호작용 확인 + AI 분석
+
+ `; + footer.style.display = 'none'; + modal.classList.add('show'); + + try { + // 요청 데이터 구성 + const requestData = { + pre_serial: currentPrescription.pre_serial, + cus_code: currentPrescription.cus_code, + patient_name: currentPrescription.name, + disease_info: { + code_1: currentPrescription.st1 || '', + name_1: currentPrescription.st1_name || '', + code_2: currentPrescription.st2 || '', + name_2: currentPrescription.st2_name || '' + }, + current_medications: (currentPrescription.medications || []).map(med => ({ + code: med.medication_code, + name: med.med_name, + dosage: med.dosage, + frequency: med.frequency, + days: med.days + })), + previous_serial: currentPrescription.previous_serial || '', + previous_medications: (currentPrescription.previous_medications || []).map(med => ({ + code: med.medication_code, + name: med.med_name, + dosage: med.dosage, + frequency: med.frequency, + days: med.days + })), + otc_history: otcData ? { + visit_count: otcData.summary?.total_visits || 0, + frequent_items: otcData.summary?.frequent_items || [], + purchases: otcData.purchases || [] + } : {} + }; + + const response = await fetch('/pmr/api/paai/analyze', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + + if (result.success) { + currentPaaiLogId = result.log_id; + displayPaaiResult(result); + } else { + throw new Error(result.error || '분석 실패'); + } + + } catch (err) { + console.error('PAAI error:', err); + body.innerHTML = ` +
+
⚠️
+
분석 중 오류가 발생했습니다
+
${err.message}
+
+ `; + } + } + + function displayPaaiResult(result) { + const body = document.getElementById('paaiBody'); + const footer = document.getElementById('paaiFooter'); + const timing = document.getElementById('paaiTiming'); + + const analysis = result.analysis || {}; + const kims = result.kims_summary || {}; + + let html = ''; + + // KIMS 상호작용 요약 + if (kims.interaction_count > 0) { + html += ` +
+
⚠️ KIMS 상호작용 (${kims.interaction_count}건)
+
+ ${analysis.kims_analysis || 'KIMS 상호작용이 감지되었습니다. 상세 내용을 확인하세요.'} +
+
+ `; + } + + // 처방 분석 + if (analysis.prescription_insight) { + html += ` +
+
📋 처방 분석
+
${analysis.prescription_insight}
+
+ `; + } + + // 복용 주의사항 + if (analysis.cautions && analysis.cautions.length > 0) { + html += ` +
+
⚡ 복용 주의사항
+
+
    + ${analysis.cautions.map(c => `
  • ${c}
  • `).join('')} +
+
+
+ `; + } + + // OTC 추천 + if (analysis.otc_recommendations && analysis.otc_recommendations.length > 0) { + html += ` +
+
💊 OTC 추천
+
+ ${analysis.otc_recommendations.map(rec => ` +
+
${rec.product}
+
${rec.reason}
+
+ `).join('')} +
+
+ `; + } + + // 상담 포인트 + if (analysis.counseling_points && analysis.counseling_points.length > 0) { + html += ` +
+
💬 상담 포인트
+
+
    + ${analysis.counseling_points.map(p => `
  • ${p}
  • `).join('')} +
+
+
+ `; + } + + // fallback 메시지 + if (analysis._fallback) { + html += ` +
+ ⚠️ AI 서비스 연결 불가 - KIMS 데이터만 표시됨 +
+ `; + } + + body.innerHTML = html || '
분석 결과가 없습니다.
'; + + // 타이밍 정보 + if (result.timing) { + timing.textContent = `KIMS: ${result.timing.kims_ms}ms / AI: ${result.timing.ai_ms}ms / 총: ${result.timing.total_ms}ms`; + } + + // 피드백 버튼 초기화 + document.getElementById('paaiUseful').classList.remove('selected'); + document.getElementById('paaiNotUseful').classList.remove('selected'); + + footer.style.display = 'flex'; + } + + function closePaaiModal() { + document.getElementById('paaiModal').classList.remove('show'); + } + + async function sendPaaiFeedback(useful) { + if (!currentPaaiLogId) return; + + try { + await fetch('/pmr/api/paai/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + log_id: currentPaaiLogId, + useful: useful + }) + }); + + // 버튼 표시 업데이트 + document.getElementById('paaiUseful').classList.toggle('selected', useful); + document.getElementById('paaiNotUseful').classList.toggle('selected', !useful); + + } catch (err) { + console.error('Feedback error:', err); + } + } + + // ───────────────────────────────────────────────────────────── + // 상세 초기화 function clearDetail() { document.getElementById('detailHeader').style.display = 'none';