diff --git a/backend/app.py b/backend/app.py index 3bb7704..af8f4a1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -56,6 +56,9 @@ app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=90) # 3개월 유지 from pmr_api import pmr_bp app.register_blueprint(pmr_bp) +from paai_feedback import paai_feedback_bp +app.register_blueprint(paai_feedback_bp) + # 데이터베이스 매니저 db_manager = DatabaseManager() diff --git a/backend/paai_feedback.py b/backend/paai_feedback.py new file mode 100644 index 0000000..0b1268f --- /dev/null +++ b/backend/paai_feedback.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- +""" +PAAI 피드백 루프 시스템 +- 피드백 수집, AI 정제, 프롬프트 인젝션 +""" + +import sqlite3 +import os +import json +from datetime import datetime +from flask import Blueprint, request, jsonify + +paai_feedback_bp = Blueprint('paai_feedback', __name__) + +# DB 경로 +DB_PATH = os.path.join(os.path.dirname(__file__), 'db', 'paai_feedback.db') + +def get_db(): + """DB 연결""" + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def init_db(): + """테이블 초기화""" + conn = get_db() + conn.execute(''' + CREATE TABLE IF NOT EXISTS paai_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- 컨텍스트 + prescription_id TEXT, + patient_name TEXT, + patient_context TEXT, + + -- PAAI 응답 + paai_request TEXT, + paai_response TEXT, + + -- 피드백 + rating TEXT, + category TEXT, + pharmacist_comment TEXT, + + -- AI 정제 결과 + refined_rule TEXT, + confidence REAL, + + -- 적용 상태 + applied_to_prompt INTEGER DEFAULT 0, + applied_to_training INTEGER DEFAULT 0 + ) + ''') + conn.commit() + conn.close() + +# 앱 시작 시 테이블 생성 +init_db() + + +@paai_feedback_bp.route('/api/paai/feedback', methods=['POST']) +def submit_feedback(): + """피드백 제출""" + try: + data = request.json + + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + INSERT INTO paai_feedback ( + prescription_id, patient_name, patient_context, + paai_request, paai_response, + rating, category, pharmacist_comment + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + data.get('prescription_id'), + data.get('patient_name'), + json.dumps(data.get('patient_context', {}), ensure_ascii=False), + data.get('paai_request'), + data.get('paai_response'), + data.get('rating'), # 'good' or 'bad' + data.get('category'), # 'interaction', 'indication', 'dosage', 'other' + data.get('pharmacist_comment') + )) + + feedback_id = cursor.lastrowid + conn.commit() + + # bad 피드백이고 코멘트가 있으면 AI 정제 시도 + if data.get('rating') == 'bad' and data.get('pharmacist_comment'): + refined = refine_feedback_async(feedback_id, data) + if refined: + cursor.execute(''' + UPDATE paai_feedback + SET refined_rule = ?, confidence = ? + WHERE id = ? + ''', (refined['rule'], refined['confidence'], feedback_id)) + conn.commit() + + conn.close() + + return jsonify({ + 'success': True, + 'feedback_id': feedback_id, + 'message': '피드백이 저장되었습니다.' + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def refine_feedback_async(feedback_id, data): + """피드백을 규칙으로 정제 (동기 버전 - 나중에 비동기로 변경 가능)""" + try: + # TODO: AI 호출로 정제 + # 지금은 간단히 코멘트를 규칙 형태로 저장 + comment = data.get('pharmacist_comment', '') + if not comment: + return None + + # 간단한 규칙 형태로 변환 + category = data.get('category', 'other') + rule = f"[{category}] {comment}" + + return { + 'rule': rule, + 'confidence': 0.8 # 기본 신뢰도 + } + except: + return None + + +@paai_feedback_bp.route('/api/paai/feedback/rules', methods=['GET']) +def get_feedback_rules(): + """축적된 피드백 규칙 조회 (프롬프트 인젝션용)""" + try: + conn = get_db() + cursor = conn.cursor() + + # bad 피드백 중 정제된 규칙만 + cursor.execute(''' + SELECT refined_rule, category, created_at + FROM paai_feedback + WHERE rating = 'bad' + AND refined_rule IS NOT NULL + ORDER BY created_at DESC + LIMIT 20 + ''') + + rules = [] + for row in cursor.fetchall(): + rules.append({ + 'rule': row['refined_rule'], + 'category': row['category'], + 'created_at': row['created_at'] + }) + + conn.close() + + return jsonify({ + 'success': True, + 'rules': rules, + 'count': len(rules) + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +@paai_feedback_bp.route('/api/paai/feedback/stats', methods=['GET']) +def get_feedback_stats(): + """피드백 통계""" + try: + conn = get_db() + cursor = conn.cursor() + + cursor.execute(''' + SELECT + COUNT(*) as total, + SUM(CASE WHEN rating = 'good' THEN 1 ELSE 0 END) as good, + SUM(CASE WHEN rating = 'bad' THEN 1 ELSE 0 END) as bad + FROM paai_feedback + ''') + row = cursor.fetchone() + + conn.close() + + return jsonify({ + 'success': True, + 'stats': { + 'total': row['total'] or 0, + 'good': row['good'] or 0, + 'bad': row['bad'] or 0 + } + }) + + except Exception as e: + return jsonify({'success': False, 'error': str(e)}), 500 + + +def get_rules_for_prompt(patient_context=None): + """프롬프트에 주입할 규칙 목록 반환""" + try: + conn = get_db() + cursor = conn.cursor() + + # 최근 규칙 20개 + cursor.execute(''' + SELECT refined_rule + FROM paai_feedback + WHERE rating = 'bad' + AND refined_rule IS NOT NULL + ORDER BY created_at DESC + LIMIT 20 + ''') + + rules = [row['refined_rule'] for row in cursor.fetchall()] + conn.close() + + return rules + except: + return [] diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html index 1e43a2c..1202763 100644 --- a/backend/templates/pmr.html +++ b/backend/templates/pmr.html @@ -117,7 +117,7 @@ /* 왼쪽: 환자 목록 */ .patient-list { - width: 380px; + width: clamp(250px, 22vw, 380px); background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); @@ -125,6 +125,12 @@ flex-direction: column; overflow: hidden; } + /* 세로 모니터 최적화 */ + @media (orientation: portrait) { + .patient-list { + width: clamp(220px, 18vw, 300px); + } + } .patient-list-header { background: #4c1d95; color: #fff; @@ -415,11 +421,99 @@ } .paai-feedback button:hover { border-color: #10b981; } .paai-feedback button.selected { background: #d1fae5; border-color: #10b981; } + .paai-feedback button.selected-bad { background: #fee2e2; border-color: #ef4444; } .paai-timing { font-size: 0.8rem; color: #9ca3af; } + /* 피드백 코멘트 모달 */ + .feedback-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 1200; + justify-content: center; + align-items: center; + } + .feedback-modal.show { display: flex; } + .feedback-modal-content { + background: #fff; + border-radius: 16px; + width: 90%; + max-width: 500px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); + } + .feedback-modal-header { + background: linear-gradient(135deg, #ef4444, #dc2626); + color: #fff; + padding: 18px 24px; + border-radius: 16px 16px 0 0; + display: flex; + justify-content: space-between; + align-items: center; + } + .feedback-modal-header h3 { font-size: 1.1rem; margin: 0; } + .feedback-modal-close { + background: rgba(255,255,255,0.2); + border: none; + color: #fff; + width: 32px; + height: 32px; + border-radius: 50%; + font-size: 1.3rem; + cursor: pointer; + } + .feedback-modal-body { padding: 24px; } + .feedback-categories { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; + } + .feedback-category { + padding: 6px 14px; + border: 2px solid #e5e7eb; + border-radius: 20px; + background: #fff; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; + } + .feedback-category:hover { border-color: #ef4444; } + .feedback-category.selected { background: #fee2e2; border-color: #ef4444; color: #dc2626; } + .feedback-textarea { + width: 100%; + min-height: 100px; + padding: 12px; + border: 2px solid #e5e7eb; + border-radius: 10px; + font-size: 0.95rem; + resize: vertical; + font-family: inherit; + } + .feedback-textarea:focus { outline: none; border-color: #ef4444; } + .feedback-modal-footer { + padding: 16px 24px; + border-top: 1px solid #e5e7eb; + display: flex; + justify-content: flex-end; + gap: 10px; + } + .feedback-btn { + padding: 10px 20px; + border-radius: 8px; + font-size: 0.95rem; + cursor: pointer; + border: none; + } + .feedback-btn-cancel { background: #f3f4f6; color: #374151; } + .feedback-btn-submit { background: #ef4444; color: #fff; font-weight: 600; } + /* PAAI 토스트 알림 */ .paai-toast-container { position: fixed; @@ -1169,6 +1263,29 @@ + +
+
+ + + +
+
+
@@ -2201,6 +2318,7 @@ } currentPaaiLogId = cached.result.log_id; + currentPaaiResponse = JSON.stringify(cached.result.analysis || {}); displayPaaiResult(cached.result); modal.classList.add('show'); } @@ -2325,28 +2443,93 @@ document.getElementById('paaiModal').classList.remove('show'); } + // 피드백 관련 변수 + let currentPaaiResponse = ''; + let currentFeedbackCategory = 'other'; + async function sendPaaiFeedback(useful) { if (!currentPaaiLogId) return; // 버튼 즉시 반영 document.getElementById('paaiUseful').classList.toggle('selected', useful); - document.getElementById('paaiNotUseful').classList.toggle('selected', !useful); + document.getElementById('paaiNotUseful').classList.toggle('selected-bad', !useful); + document.getElementById('paaiNotUseful').classList.remove('selected'); + + if (useful) { + // 👍 좋아요: 바로 저장하고 닫기 + try { + await fetch('/api/paai/feedback', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + prescription_id: currentPrescriptionId, + patient_name: document.querySelector('.patient-name')?.textContent || '', + paai_response: currentPaaiResponse, + rating: 'good' + }) + }); + } catch (err) { + console.error('Feedback error:', err); + } + setTimeout(() => closePaaiModal(), 500); + } else { + // 👎 아니요: 코멘트 모달 열기 + openFeedbackModal(); + } + } + + function openFeedbackModal() { + document.getElementById('feedbackModal').classList.add('show'); + document.getElementById('feedbackComment').value = ''; + document.getElementById('feedbackComment').focus(); + + // 카테고리 버튼 이벤트 + document.querySelectorAll('.feedback-category').forEach(btn => { + btn.classList.remove('selected'); + btn.onclick = () => { + document.querySelectorAll('.feedback-category').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + currentFeedbackCategory = btn.dataset.cat; + }; + }); + } + + function closeFeedbackModal() { + document.getElementById('feedbackModal').classList.remove('show'); + } + + async function submitFeedbackComment() { + const comment = document.getElementById('feedbackComment').value.trim(); + + if (!comment) { + alert('코멘트를 입력해주세요.'); + return; + } try { - await fetch('/pmr/api/paai/feedback', { + const res = await fetch('/api/paai/feedback', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - log_id: currentPaaiLogId, - useful: useful + prescription_id: currentPrescriptionId, + patient_name: document.querySelector('.patient-name')?.textContent || '', + paai_response: currentPaaiResponse, + rating: 'bad', + category: currentFeedbackCategory, + pharmacist_comment: comment }) }); + + const data = await res.json(); + if (data.success) { + closeFeedbackModal(); + closePaaiModal(); + showPaaiToast('피드백이 저장되었습니다. 감사합니다! 🙏', 'success'); + } } catch (err) { console.error('Feedback error:', err); + alert('피드백 저장 실패'); } - - // 0.5초 후 모달 닫기 - setTimeout(() => closePaaiModal(), 500); } // ───────────────────────────────────────────────────────────── diff --git a/docs/세로모니터_레이아웃_개선_계획.md b/docs/세로모니터_레이아웃_개선_계획.md new file mode 100644 index 0000000..b6d4afe --- /dev/null +++ b/docs/세로모니터_레이아웃_개선_계획.md @@ -0,0 +1,170 @@ +# PMR 세로 모니터 레이아웃 개선 계획 + +## 현재 상황 + +### 환경 +- 모니터: 2.5K (2560x1440) 세로 모드 → 1440x2560 +- 문제: 환자목록과 처방전 내용이 **거의 절반씩** 차지 +- 환자목록은 그렇게 넓을 필요 없음 + +### 현재 CSS 구조 +```css +.main-content { + display: flex; + gap: 20px; +} +.patient-list { + width: 380px; /* 고정 너비 */ +} +.prescription-panel { + flex: 1; /* 나머지 공간 */ +} +``` + +### 세로 모니터에서의 문제 +- 화면 너비: 1440px +- 환자목록: 380px (26%) +- 처방전: ~1040px (72%) +- **실제로는 환자목록이 26%인데 "절반처럼" 느껴짐** → 세로가 길어서 상대적으로 넓어 보임 + +--- + +## 해결 방안 비교 + +### 방안 1: 미디어쿼리 (aspect-ratio) +```css +/* 세로 모니터 감지 (높이 > 너비) */ +@media (orientation: portrait) { + .patient-list { + width: 280px; /* 더 좁게 */ + } +} +``` + +**장점:** +- 간단, 직관적 +- 기존 코드 영향 최소화 + +**단점:** +- 세로 모니터 전용 스타일 분기 필요 + +--- + +### 방안 2: CSS Container Queries +```css +.main-content { + container-type: inline-size; +} +@container (max-width: 1500px) { + .patient-list { + width: 280px; + } +} +``` + +**장점:** +- 모던한 접근 +- 컨테이너 기준으로 반응 + +**단점:** +- 브라우저 지원 확인 필요 (Chrome 105+) + +--- + +### 방안 3: 환자목록 비율 기반 (추천 ⭐) +```css +.patient-list { + width: 280px; + min-width: 250px; + max-width: 380px; +} +``` + +또는: +```css +.patient-list { + width: clamp(250px, 20vw, 380px); +} +``` + +**장점:** +- 미디어쿼리 없이 자동 조절 +- 모든 화면에서 적절한 비율 유지 +- **가장 단순함** + +**단점:** +- 특정 breakpoint 세밀 조정 어려움 + +--- + +### 방안 4: 세로 모니터 전용 레이아웃 +```css +@media (orientation: portrait) and (min-height: 1800px) { + .main-content { + flex-direction: column; + } + .patient-list { + width: 100%; + height: 200px; /* 상단 고정 */ + } +} +``` + +**장점:** +- 세로 모니터 최적화 + +**단점:** +- 레이아웃 완전 변경 → 복잡 +- UX 변화 큼 + +--- + +## 추천 방안: **방안 3 + 방안 1 조합** + +### 구현 +```css +/* 기본: 비율 기반 너비 */ +.patient-list { + width: clamp(250px, 22vw, 380px); +} + +/* 세로 모니터에서 더 좁게 */ +@media (orientation: portrait) { + .patient-list { + width: clamp(220px, 18vw, 300px); + } +} +``` + +### 이유 +1. **clamp()**: 최소/최대 범위 내에서 자동 조절 +2. **portrait 미디어쿼리**: 세로 모니터 추가 최적화 +3. **코드 2줄 추가**로 해결 가능 + +--- + +## 작업 범위 + +### 변경 파일 +- `pmr.html` - CSS 수정 (약 5줄) + +### 테스트 +- 가로 모니터 (기존 동작 유지) +- 세로 모니터 (환자목록 좁아짐) +- 반응형 resize + +--- + +## 예상 결과 + +| 모드 | 환자목록 너비 | 비율 | +|------|--------------|------| +| 가로 (1920px) | ~380px | 20% | +| 가로 (1440px) | ~320px | 22% | +| **세로 (1440px)** | **~260px** | **18%** | + +--- + +## 승인 시 진행 + +약사님 확인 후 바로 구현 가능합니다.