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 @@ + +