Compare commits
4 Commits
1b33f82fd4
...
f3b6496c91
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3b6496c91 | ||
|
|
16c3881661 | ||
|
|
59a55d6b22 | ||
|
|
4275689c29 |
@ -1111,52 +1111,48 @@ def build_paai_prompt(
|
||||
|
||||
|
||||
def call_clawdbot_ai(prompt: str) -> dict:
|
||||
"""Clawdbot AI 호출 (HTTP API)"""
|
||||
import requests as http_requests
|
||||
"""Clawdbot AI 호출 (WebSocket Gateway)"""
|
||||
import json
|
||||
import re
|
||||
from services.clawdbot_client import ask_clawdbot
|
||||
|
||||
PAAI_SYSTEM_PROMPT = """당신은 경험 많은 약사입니다.
|
||||
처방 데이터를 분석하여 약사에게 유용한 정보를 제공합니다.
|
||||
반드시 요청된 JSON 형식으로만 응답하세요."""
|
||||
|
||||
try:
|
||||
# Clawdbot Gateway API 호출
|
||||
response = http_requests.post(
|
||||
'http://localhost:8765/api/chat',
|
||||
json={
|
||||
'message': prompt,
|
||||
'session': 'paai-analysis',
|
||||
'timeout': 60
|
||||
},
|
||||
timeout=65
|
||||
# Clawdbot Gateway WebSocket API 호출
|
||||
ai_text = ask_clawdbot(
|
||||
message=prompt,
|
||||
session_id='paai-analysis',
|
||||
system_prompt=PAAI_SYSTEM_PROMPT,
|
||||
timeout=60,
|
||||
model='anthropic/claude-sonnet-4-5' # 빠른 Sonnet 사용
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
# AI 응답에서 JSON 파싱 시도
|
||||
ai_text = result.get('response', '')
|
||||
if not ai_text:
|
||||
logging.warning("[PAAI] Clawdbot 응답 없음")
|
||||
return generate_fallback_response(prompt)
|
||||
|
||||
# JSON 블록 추출
|
||||
try:
|
||||
json_match = re.search(r'\{[\s\S]*\}', ai_text)
|
||||
if json_match:
|
||||
return json.loads(json_match.group())
|
||||
except Exception as parse_err:
|
||||
logging.warning(f"[PAAI] JSON 파싱 실패: {parse_err}")
|
||||
|
||||
# JSON 파싱 실패 시 텍스트 그대로 반환
|
||||
return {
|
||||
'prescription_insight': ai_text[:500] if ai_text else '분석 결과 없음',
|
||||
'kims_analysis': '',
|
||||
'cautions': [],
|
||||
'otc_recommendations': [],
|
||||
'counseling_points': []
|
||||
}
|
||||
|
||||
# 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}")
|
||||
logging.error(f"[PAAI] Clawdbot AI 호출 오류: {e}")
|
||||
return generate_fallback_response(prompt)
|
||||
|
||||
|
||||
@ -1193,3 +1189,132 @@ def paai_feedback():
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 피드백 저장 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
# PAAI 어드민 페이지
|
||||
# ────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pmr_bp.route('/admin')
|
||||
def paai_admin_page():
|
||||
"""PAAI 어드민 대시보드 페이지"""
|
||||
return render_template('pmr_admin.html')
|
||||
|
||||
|
||||
@pmr_bp.route('/api/admin/stats')
|
||||
def paai_admin_stats():
|
||||
"""PAAI 통계 API"""
|
||||
from db.paai_logger import get_stats
|
||||
|
||||
try:
|
||||
stats = get_stats()
|
||||
return jsonify({'success': True, 'stats': stats})
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@pmr_bp.route('/api/admin/logs')
|
||||
def paai_admin_logs():
|
||||
"""PAAI 로그 목록 API"""
|
||||
from db.paai_logger import get_recent_logs
|
||||
|
||||
try:
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
status = request.args.get('status')
|
||||
has_severe = request.args.get('has_severe')
|
||||
date = request.args.get('date')
|
||||
patient_name = request.args.get('patient_name')
|
||||
|
||||
# has_severe 파싱
|
||||
if has_severe == 'true':
|
||||
has_severe = True
|
||||
elif has_severe == 'false':
|
||||
has_severe = False
|
||||
else:
|
||||
has_severe = None
|
||||
|
||||
logs = get_recent_logs(
|
||||
limit=limit,
|
||||
status=status,
|
||||
has_severe=has_severe,
|
||||
date=date
|
||||
)
|
||||
|
||||
# 환자명 필터링 (클라이언트 사이드에서 하기엔 데이터가 많을 수 있음)
|
||||
if patient_name:
|
||||
logs = [log for log in logs if patient_name.lower() in (log.get('patient_name') or '').lower()]
|
||||
|
||||
return jsonify({'success': True, 'logs': logs, 'count': len(logs)})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 로그 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@pmr_bp.route('/api/admin/log/<int:log_id>')
|
||||
def paai_admin_log_detail(log_id):
|
||||
"""PAAI 로그 상세 API"""
|
||||
from db.paai_logger import get_log_detail
|
||||
|
||||
try:
|
||||
log = get_log_detail(log_id)
|
||||
if not log:
|
||||
return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다'}), 404
|
||||
|
||||
return jsonify({'success': True, 'log': log})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 로그 상세 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@pmr_bp.route('/api/admin/feedback-stats')
|
||||
def paai_admin_feedback_stats():
|
||||
"""피드백 통계 API (일별)"""
|
||||
from db.paai_logger import DB_PATH
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
if not DB_PATH.exists():
|
||||
return jsonify({'success': True, 'stats': []})
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 최근 30일 일별 통계
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
DATE(created_at) as date,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN feedback_useful = 1 THEN 1 ELSE 0 END) as useful,
|
||||
SUM(CASE WHEN feedback_useful = 0 THEN 1 ELSE 0 END) as not_useful,
|
||||
SUM(CASE WHEN feedback_useful IS NULL THEN 1 ELSE 0 END) as no_feedback,
|
||||
SUM(CASE WHEN kims_has_severe = 1 THEN 1 ELSE 0 END) as severe,
|
||||
AVG(ai_response_time_ms) as avg_ai_time
|
||||
FROM paai_logs
|
||||
WHERE created_at >= date('now', '-30 days')
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date DESC
|
||||
""")
|
||||
|
||||
rows = cursor.fetchall()
|
||||
stats = []
|
||||
for row in rows:
|
||||
stats.append({
|
||||
'date': row[0],
|
||||
'total': row[1],
|
||||
'useful': row[2] or 0,
|
||||
'not_useful': row[3] or 0,
|
||||
'no_feedback': row[4] or 0,
|
||||
'severe': row[5] or 0,
|
||||
'avg_ai_time': int(row[6]) if row[6] else 0
|
||||
})
|
||||
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'stats': stats})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"피드백 통계 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@ -322,6 +322,96 @@
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* PAAI 토스트 알림 */
|
||||
.paai-toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.paai-toast {
|
||||
pointer-events: auto;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.4);
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 280px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.paai-toast:hover {
|
||||
transform: translateX(-5px);
|
||||
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
.paai-toast .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.paai-toast .content {
|
||||
flex: 1;
|
||||
}
|
||||
.paai-toast .title {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.paai-toast .subtitle {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.paai-toast .close-btn {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.paai-toast .close-btn:hover {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
.paai-toast.removing {
|
||||
animation: slideOut 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
/* PAAI 버튼 로딩 상태 */
|
||||
.paai-badge.loading {
|
||||
background: linear-gradient(135deg, #6b7280, #9ca3af) !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
.paai-badge .spinner-small {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* OTC 모달 */
|
||||
.otc-modal {
|
||||
display: none;
|
||||
@ -689,6 +779,9 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- PAAI 토스트 컨테이너 -->
|
||||
<div class="paai-toast-container" id="paaiToastContainer"></div>
|
||||
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<h1>💊 조제관리 <span>청춘라벨 v2</span></h1>
|
||||
@ -829,6 +922,7 @@
|
||||
let historyIndex = 0;
|
||||
let compareMode = false;
|
||||
let otcData = null;
|
||||
let currentPrescriptionData = null; // PAAI용 처방 데이터
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
@ -922,6 +1016,18 @@
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// PAAI용 처방 데이터 저장
|
||||
currentPrescriptionData = {
|
||||
pre_serial: prescriptionId,
|
||||
cus_code: data.patient.cus_code,
|
||||
name: data.patient.name,
|
||||
st1: data.disease_info?.code_1 || '',
|
||||
st1_name: data.disease_info?.name_1 || '',
|
||||
st2: data.disease_info?.code_2 || '',
|
||||
st2_name: data.disease_info?.name_2 || '',
|
||||
medications: data.medications || []
|
||||
};
|
||||
|
||||
// 헤더 업데이트
|
||||
document.getElementById('detailHeader').style.display = 'block';
|
||||
document.getElementById('detailName').textContent = data.patient.name || '이름없음';
|
||||
@ -1363,74 +1469,111 @@
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PAAI (Pharmacist Assistant AI) 함수들
|
||||
// PAAI (Pharmacist Assistant AI) 함수들 - 비동기 토스트 방식
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let currentPaaiLogId = null;
|
||||
const paaiResultCache = {}; // 환자별 분석 결과 캐시: { pre_serial: { result, patientName } }
|
||||
const paaiPendingRequests = new Set(); // 진행 중인 요청
|
||||
|
||||
function addPaaiButton() {
|
||||
const rxInfo = document.getElementById('rxInfo');
|
||||
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
|
||||
|
||||
const preSerial = currentPrescriptionData?.pre_serial;
|
||||
const paaiBtn = document.createElement('span');
|
||||
paaiBtn.className = 'paai-badge';
|
||||
paaiBtn.textContent = '🤖 PAAI 분석';
|
||||
paaiBtn.onclick = showPaaiModal;
|
||||
paaiBtn.id = 'paaiBtn';
|
||||
|
||||
// 캐시에 결과가 있으면 "결과 보기" 버튼
|
||||
if (preSerial && paaiResultCache[preSerial]) {
|
||||
paaiBtn.innerHTML = '✅ PAAI 결과 보기';
|
||||
paaiBtn.onclick = () => openPaaiResultModal(preSerial);
|
||||
}
|
||||
// 진행 중이면 로딩 상태
|
||||
else if (preSerial && paaiPendingRequests.has(preSerial)) {
|
||||
paaiBtn.classList.add('loading');
|
||||
paaiBtn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||||
paaiBtn.onclick = null;
|
||||
}
|
||||
// 기본: 분석 버튼
|
||||
else {
|
||||
paaiBtn.innerHTML = '🤖 PAAI 분석';
|
||||
paaiBtn.onclick = triggerPaaiAnalysis;
|
||||
}
|
||||
|
||||
rxInfo.appendChild(paaiBtn);
|
||||
}
|
||||
|
||||
async function showPaaiModal() {
|
||||
if (!currentPrescription) return;
|
||||
// 비동기 분석 트리거 (모달 열지 않음)
|
||||
async function triggerPaaiAnalysis() {
|
||||
if (!currentPrescriptionData) return;
|
||||
|
||||
const modal = document.getElementById('paaiModal');
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
const preSerial = currentPrescriptionData.pre_serial;
|
||||
const patientName = currentPrescriptionData.name || '환자';
|
||||
|
||||
// 초기화
|
||||
body.innerHTML = `
|
||||
<div class="paai-loading">
|
||||
<div class="spinner"></div>
|
||||
<div>AI 분석 중...</div>
|
||||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">KIMS 상호작용 확인 + AI 분석</div>
|
||||
</div>
|
||||
`;
|
||||
footer.style.display = 'none';
|
||||
modal.classList.add('show');
|
||||
// 이미 진행 중이면 무시
|
||||
if (paaiPendingRequests.has(preSerial)) {
|
||||
showPaaiToast(patientName, '이미 분석 중입니다...', 'pending', preSerial);
|
||||
return;
|
||||
}
|
||||
|
||||
// 캐시에 있으면 바로 모달 열기
|
||||
if (paaiResultCache[preSerial]) {
|
||||
openPaaiResultModal(preSerial);
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼 로딩 상태
|
||||
const btn = document.getElementById('paaiBtn');
|
||||
if (btn) {
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||||
}
|
||||
|
||||
// 진행 중 표시
|
||||
paaiPendingRequests.add(preSerial);
|
||||
|
||||
// 요청 데이터 구성 (현재 환자 데이터 스냅샷 저장)
|
||||
const requestSnapshot = {
|
||||
pre_serial: preSerial,
|
||||
cus_code: currentPrescriptionData.cus_code,
|
||||
patient_name: patientName,
|
||||
disease_info: {
|
||||
code_1: currentPrescriptionData.st1 || '',
|
||||
name_1: currentPrescriptionData.st1_name || '',
|
||||
code_2: currentPrescriptionData.st2 || '',
|
||||
name_2: currentPrescriptionData.st2_name || ''
|
||||
},
|
||||
current_medications: (currentPrescriptionData.medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.duration // 백엔드는 duration 필드 사용
|
||||
})),
|
||||
previous_serial: currentPrescriptionData.previous_serial || '',
|
||||
previous_medications: (currentPrescriptionData.previous_medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.duration // 백엔드는 duration 필드 사용
|
||||
})),
|
||||
otc_history: otcData ? {
|
||||
visit_count: otcData.summary?.total_visits || 0,
|
||||
frequent_items: otcData.summary?.frequent_items || [],
|
||||
purchases: otcData.purchases || []
|
||||
} : {}
|
||||
};
|
||||
|
||||
// 비동기 분석 실행 (await 없이 백그라운드)
|
||||
performPaaiAnalysis(preSerial, patientName, requestSnapshot);
|
||||
}
|
||||
|
||||
// 실제 분석 수행 (백그라운드)
|
||||
async function performPaaiAnalysis(preSerial, patientName, requestData) {
|
||||
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'},
|
||||
@ -1440,24 +1583,134 @@
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
currentPaaiLogId = result.log_id;
|
||||
displayPaaiResult(result);
|
||||
// 캐시에 저장
|
||||
paaiResultCache[preSerial] = {
|
||||
result: result,
|
||||
patientName: patientName,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 토스트 알림
|
||||
showPaaiToast(patientName, 'PAAI 분석 완료! 클릭하여 확인', 'success', preSerial);
|
||||
} else {
|
||||
throw new Error(result.error || '분석 실패');
|
||||
showPaaiToast(patientName, '분석 실패: ' + (result.error || '알 수 없는 오류'), 'error', preSerial);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('PAAI error:', err);
|
||||
body.innerHTML = `
|
||||
<div style="text-align:center;padding:40px;color:#ef4444;">
|
||||
<div style="font-size:2rem;margin-bottom:15px;">⚠️</div>
|
||||
<div>분석 중 오류가 발생했습니다</div>
|
||||
<div style="font-size:0.85rem;color:#9ca3af;margin-top:10px;">${err.message}</div>
|
||||
</div>
|
||||
`;
|
||||
showPaaiToast(patientName, '분석 오류: ' + err.message, 'error', preSerial);
|
||||
} finally {
|
||||
paaiPendingRequests.delete(preSerial);
|
||||
|
||||
// 현재 보고 있는 환자면 버튼 상태 업데이트
|
||||
if (currentPrescriptionData?.pre_serial === preSerial) {
|
||||
updatePaaiButtonState(preSerial);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PAAI 버튼 상태 업데이트
|
||||
function updatePaaiButtonState(preSerial) {
|
||||
const btn = document.getElementById('paaiBtn');
|
||||
if (!btn) return;
|
||||
|
||||
btn.classList.remove('loading');
|
||||
|
||||
// 캐시에 결과가 있으면 "결과 보기"
|
||||
if (paaiResultCache[preSerial]) {
|
||||
btn.innerHTML = '✅ PAAI 결과 보기';
|
||||
btn.onclick = () => openPaaiResultModal(preSerial);
|
||||
}
|
||||
// 진행 중이면 로딩
|
||||
else if (paaiPendingRequests.has(preSerial)) {
|
||||
btn.classList.add('loading');
|
||||
btn.innerHTML = '<span class="spinner-small"></span>분석 중...';
|
||||
btn.onclick = null;
|
||||
}
|
||||
// 기본
|
||||
else {
|
||||
btn.innerHTML = '🤖 PAAI 분석';
|
||||
btn.onclick = triggerPaaiAnalysis;
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 알림 표시
|
||||
function showPaaiToast(patientName, message, type, preSerial) {
|
||||
const container = document.getElementById('paaiToastContainer');
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'paai-toast';
|
||||
toast.dataset.preSerial = preSerial;
|
||||
|
||||
const icon = type === 'success' ? '✅' : type === 'error' ? '❌' : '⏳';
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="icon">${icon}</div>
|
||||
<div class="content">
|
||||
<div class="title">${escapeHtml(patientName)}님</div>
|
||||
<div class="subtitle">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
<button class="close-btn" onclick="event.stopPropagation(); removePaaiToast(this.parentElement);">×</button>
|
||||
`;
|
||||
|
||||
// 성공이면 클릭 시 모달 열기
|
||||
if (type === 'success') {
|
||||
toast.onclick = () => {
|
||||
openPaaiResultModal(preSerial);
|
||||
removePaaiToast(toast);
|
||||
};
|
||||
}
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// 에러/대기 토스트는 5초 후 자동 제거, 성공은 15초
|
||||
const timeout = type === 'success' ? 15000 : 5000;
|
||||
setTimeout(() => removePaaiToast(toast), timeout);
|
||||
}
|
||||
|
||||
// 토스트 제거
|
||||
function removePaaiToast(toast) {
|
||||
if (!toast || !toast.parentElement) return;
|
||||
toast.classList.add('removing');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}
|
||||
|
||||
// 캐시된 결과로 모달 열기
|
||||
function openPaaiResultModal(preSerial) {
|
||||
const cached = paaiResultCache[preSerial];
|
||||
if (!cached) return;
|
||||
|
||||
const modal = document.getElementById('paaiModal');
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
|
||||
// 모달 헤더 업데이트 (환자명 표시)
|
||||
const header = modal.querySelector('.paai-modal-header h3');
|
||||
if (header) {
|
||||
header.textContent = `🤖 PAAI 분석 - ${cached.patientName}님`;
|
||||
}
|
||||
|
||||
currentPaaiLogId = cached.result.log_id;
|
||||
displayPaaiResult(cached.result);
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
// 기존 방식으로 모달 열기 (현재 환자)
|
||||
async function showPaaiModal() {
|
||||
if (!currentPrescriptionData) return;
|
||||
|
||||
const preSerial = currentPrescriptionData.pre_serial;
|
||||
|
||||
// 캐시에 있으면 바로 표시
|
||||
if (paaiResultCache[preSerial]) {
|
||||
openPaaiResultModal(preSerial);
|
||||
return;
|
||||
}
|
||||
|
||||
// 없으면 분석 트리거
|
||||
triggerPaaiAnalysis();
|
||||
}
|
||||
|
||||
function displayPaaiResult(result) {
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
@ -1565,6 +1818,10 @@
|
||||
async function sendPaaiFeedback(useful) {
|
||||
if (!currentPaaiLogId) return;
|
||||
|
||||
// 버튼 즉시 반영
|
||||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||||
|
||||
try {
|
||||
await fetch('/pmr/api/paai/feedback', {
|
||||
method: 'POST',
|
||||
@ -1574,14 +1831,12 @@
|
||||
useful: useful
|
||||
})
|
||||
});
|
||||
|
||||
// 버튼 표시 업데이트
|
||||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Feedback error:', err);
|
||||
}
|
||||
|
||||
// 0.5초 후 모달 닫기
|
||||
setTimeout(() => closePaaiModal(), 500);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@ -1705,6 +1960,316 @@
|
||||
}
|
||||
alert(`선택된 약품 ${selected.length}개 인쇄 기능은 추후 구현 예정입니다.\n\n${selected.join('\n')}`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 처방감지 트리거 WebSocket 클라이언트
|
||||
// ws://localhost:8765 (prescription_trigger.py에서 실행)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
(function() {
|
||||
const TRIGGER_WS_URL = 'ws://localhost:8765';
|
||||
const TRIGGER_DEBUG = true;
|
||||
|
||||
let triggerWs = null;
|
||||
let triggerConnected = false;
|
||||
let triggerReconnectTimer = null;
|
||||
const triggerToastMap = new Map(); // pre_serial → toast element
|
||||
|
||||
function triggerLog(msg, ...args) {
|
||||
if (TRIGGER_DEBUG) console.log(`[Trigger] ${msg}`, ...args);
|
||||
}
|
||||
|
||||
// WebSocket 연결
|
||||
function triggerConnect() {
|
||||
if (triggerWs && triggerWs.readyState === WebSocket.OPEN) return;
|
||||
|
||||
try {
|
||||
triggerLog('연결 시도:', TRIGGER_WS_URL);
|
||||
triggerWs = new WebSocket(TRIGGER_WS_URL);
|
||||
|
||||
triggerWs.onopen = () => {
|
||||
triggerLog('✅ 연결됨');
|
||||
triggerConnected = true;
|
||||
updateTriggerIndicator(true);
|
||||
if (triggerReconnectTimer) {
|
||||
clearTimeout(triggerReconnectTimer);
|
||||
triggerReconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
triggerWs.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
triggerLog('이벤트:', data.event, data.data?.patient_name);
|
||||
handleTriggerEvent(data);
|
||||
} catch (e) {
|
||||
console.error('[Trigger] 파싱 실패:', e);
|
||||
}
|
||||
};
|
||||
|
||||
triggerWs.onclose = () => {
|
||||
triggerLog('연결 해제');
|
||||
triggerConnected = false;
|
||||
triggerWs = null;
|
||||
updateTriggerIndicator(false);
|
||||
|
||||
// 3초 후 재연결
|
||||
triggerReconnectTimer = setTimeout(() => {
|
||||
triggerLog('재연결 시도...');
|
||||
triggerConnect();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
triggerWs.onerror = (error) => {
|
||||
console.error('[Trigger] 오류:', error);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
console.error('[Trigger] 연결 실패:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 이벤트 처리
|
||||
function handleTriggerEvent(eventData) {
|
||||
const { event, data } = eventData;
|
||||
|
||||
switch (event) {
|
||||
case 'prescription_detected':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 감지됨...', '📋');
|
||||
break;
|
||||
case 'prescription_updated':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'pending', '처방 수정됨, 재분석...', '🔄');
|
||||
break;
|
||||
case 'prescription_deleted':
|
||||
removeTriggerToast(data.pre_serial);
|
||||
break;
|
||||
case 'analysis_started':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'generating', 'AI 분석 중...', '🤖');
|
||||
break;
|
||||
case 'analysis_completed':
|
||||
// 캐시에 저장
|
||||
paaiResultCache[data.pre_serial] = {
|
||||
result: {
|
||||
success: true,
|
||||
analysis: data.analysis,
|
||||
kims_summary: data.kims_summary,
|
||||
log_id: data.log_id
|
||||
},
|
||||
patientName: data.patient_name,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const hasSevere = data.kims_summary?.has_severe;
|
||||
showTriggerToast(
|
||||
data.pre_serial,
|
||||
data.patient_name,
|
||||
'completed',
|
||||
hasSevere ? '⚠️ 주의 필요!' : '분석 완료! 클릭하여 확인',
|
||||
hasSevere ? '⚠️' : '✅',
|
||||
true // clickable
|
||||
);
|
||||
playTriggerSound();
|
||||
break;
|
||||
case 'analysis_failed':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'failed', data.error || '분석 실패', '❌');
|
||||
break;
|
||||
case 'job_cancelled':
|
||||
showTriggerToast(data.pre_serial, data.patient_name, 'cancelled', data.reason || '취소됨', '🚫');
|
||||
setTimeout(() => removeTriggerToast(data.pre_serial), 3000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 컨테이너
|
||||
function getTriggerToastContainer() {
|
||||
let container = document.getElementById('triggerToastStack');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'triggerToastStack';
|
||||
container.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 10px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
// 토스트 표시/업데이트
|
||||
function showTriggerToast(preSerial, patientName, status, message, icon, clickable = false) {
|
||||
let toast = triggerToastMap.get(preSerial);
|
||||
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.dataset.preSerial = preSerial;
|
||||
triggerToastMap.set(preSerial, toast);
|
||||
getTriggerToastContainer().appendChild(toast);
|
||||
}
|
||||
|
||||
// 상태별 색상
|
||||
let bg = 'linear-gradient(135deg, #10b981, #059669)';
|
||||
let shadow = 'rgba(16, 185, 129, 0.4)';
|
||||
|
||||
if (status === 'pending') {
|
||||
bg = 'linear-gradient(135deg, #f59e0b, #d97706)';
|
||||
shadow = 'rgba(245, 158, 11, 0.4)';
|
||||
} else if (status === 'generating') {
|
||||
bg = 'linear-gradient(135deg, #3b82f6, #2563eb)';
|
||||
shadow = 'rgba(59, 130, 246, 0.4)';
|
||||
} else if (status === 'failed' || (status === 'completed' && icon === '⚠️')) {
|
||||
bg = 'linear-gradient(135deg, #ef4444, #dc2626)';
|
||||
shadow = 'rgba(239, 68, 68, 0.4)';
|
||||
} else if (status === 'cancelled') {
|
||||
bg = 'linear-gradient(135deg, #6b7280, #4b5563)';
|
||||
shadow = 'rgba(107, 114, 128, 0.4)';
|
||||
}
|
||||
|
||||
toast.style.cssText = `
|
||||
pointer-events: auto;
|
||||
background: ${bg};
|
||||
color: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px ${shadow};
|
||||
cursor: ${clickable ? 'pointer' : 'default'};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
transition: transform 0.2s;
|
||||
animation: triggerSlideIn 0.3s ease-out;
|
||||
`;
|
||||
|
||||
toast.innerHTML = `
|
||||
<div style="font-size: 1.5rem;">${icon}</div>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 700; font-size: 0.95rem;">${escapeHtml(patientName)}님</div>
|
||||
<div style="font-size: 0.8rem; opacity: 0.9; margin-top: 2px;">${escapeHtml(message)}</div>
|
||||
${status === 'generating' ? '<div style="margin-top: 5px; width: 100%; height: 3px; background: rgba(255,255,255,0.3); border-radius: 2px; overflow: hidden;"><div style="width: 30%; height: 100%; background: #fff; animation: triggerProgress 1s ease-in-out infinite;"></div></div>' : ''}
|
||||
</div>
|
||||
<button onclick="event.stopPropagation(); window.removeTriggerToast('${preSerial}');" style="
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
">×</button>
|
||||
`;
|
||||
|
||||
// 호버 효과
|
||||
toast.onmouseenter = () => toast.style.transform = 'translateX(-5px)';
|
||||
toast.onmouseleave = () => toast.style.transform = 'translateX(0)';
|
||||
|
||||
// 클릭 이벤트
|
||||
if (clickable && status === 'completed') {
|
||||
toast.onclick = () => {
|
||||
if (typeof openPaaiResultModal === 'function') {
|
||||
openPaaiResultModal(preSerial);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 토스트 제거
|
||||
window.removeTriggerToast = function(preSerial) {
|
||||
const toast = triggerToastMap.get(preSerial);
|
||||
if (!toast) return;
|
||||
|
||||
toast.style.animation = 'triggerSlideOut 0.3s ease-in forwards';
|
||||
setTimeout(() => {
|
||||
if (toast.parentElement) toast.parentElement.removeChild(toast);
|
||||
triggerToastMap.delete(preSerial);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 연결 상태 표시
|
||||
function updateTriggerIndicator(isConnected) {
|
||||
let indicator = document.getElementById('triggerIndicator');
|
||||
if (!indicator) {
|
||||
const controls = document.querySelector('.header .controls');
|
||||
if (controls) {
|
||||
indicator = document.createElement('div');
|
||||
indicator.id = 'triggerIndicator';
|
||||
indicator.style.cssText = `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: #f1f5f9;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
`;
|
||||
controls.insertBefore(indicator, controls.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
if (indicator) {
|
||||
indicator.innerHTML = `
|
||||
<span style="
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: ${isConnected ? '#10b981' : '#ef4444'};
|
||||
"></span>
|
||||
${isConnected ? '자동감지 ON' : '자동감지 OFF'}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 소리
|
||||
function playTriggerSound() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 800;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.1;
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.15);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// CSS 애니메이션 주입
|
||||
const triggerStyle = document.createElement('style');
|
||||
triggerStyle.textContent = `
|
||||
@keyframes triggerSlideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
@keyframes triggerSlideOut {
|
||||
from { transform: translateX(0); opacity: 1; }
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
@keyframes triggerProgress {
|
||||
0% { transform: translateX(-100%); }
|
||||
50% { transform: translateX(200%); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(triggerStyle);
|
||||
|
||||
// 초기화
|
||||
triggerConnect();
|
||||
triggerLog('처방감지 클라이언트 초기화 완료');
|
||||
|
||||
})();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
869
backend/templates/pmr_admin.html
Normal file
869
backend/templates/pmr_admin.html
Normal file
@ -0,0 +1,869 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PAAI 어드민 - 청춘약국</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
|
||||
background: #f3f4f6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 헤더 */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 15px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header .nav-links {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
.header .nav-links a {
|
||||
color: rgba(255,255,255,0.9);
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header .nav-links a:hover,
|
||||
.header .nav-links a.active {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 메인 컨테이너 */
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 통계 카드 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
.stat-card .icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.stat-card .icon.blue { background: #dbeafe; }
|
||||
.stat-card .icon.green { background: #d1fae5; }
|
||||
.stat-card .icon.yellow { background: #fef3c7; }
|
||||
.stat-card .icon.red { background: #fee2e2; }
|
||||
.stat-card .icon.purple { background: #ede9fe; }
|
||||
.stat-card .info { flex: 1; }
|
||||
.stat-card .value { font-size: 1.8rem; font-weight: 700; color: #1f2937; }
|
||||
.stat-card .label { font-size: 0.85rem; color: #6b7280; }
|
||||
|
||||
/* 섹션 */
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-header {
|
||||
background: #f9fafb;
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section-header h2 {
|
||||
font-size: 1.1rem;
|
||||
color: #374151;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.section-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 필터 */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
.filter-group input,
|
||||
.filter-group select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.filter-group input:focus,
|
||||
.filter-group select:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: #059669; }
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover { background: #d1d5db; }
|
||||
|
||||
/* 로그 테이블 */
|
||||
.log-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.log-table th {
|
||||
background: #f9fafb;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.log-table td {
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-size: 0.9rem;
|
||||
color: #4b5563;
|
||||
}
|
||||
.log-table tr:hover { background: #f9fafb; }
|
||||
.log-table .badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-success { background: #d1fae5; color: #065f46; }
|
||||
.badge-error { background: #fee2e2; color: #991b1b; }
|
||||
.badge-pending { background: #fef3c7; color: #92400e; }
|
||||
.badge-severe { background: #fee2e2; color: #dc2626; }
|
||||
.badge-useful { background: #d1fae5; color: #065f46; }
|
||||
.badge-not-useful { background: #fee2e2; color: #991b1b; }
|
||||
.badge-no-feedback { background: #e5e7eb; color: #6b7280; }
|
||||
|
||||
.log-table .actions button {
|
||||
padding: 6px 12px;
|
||||
background: #ede9fe;
|
||||
color: #7c3aed;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.log-table .actions button:hover {
|
||||
background: #ddd6fe;
|
||||
}
|
||||
|
||||
/* 모달 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 40px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
background: linear-gradient(135deg, #7c3aed, #a855f7);
|
||||
color: #fff;
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
.modal-header h3 { font-size: 1.2rem; }
|
||||
.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;
|
||||
}
|
||||
.modal-close:hover { background: rgba(255,255,255,0.3); }
|
||||
.modal-body {
|
||||
padding: 25px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 상세 로그 섹션 */
|
||||
.detail-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.detail-section-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.detail-section-title:hover { color: #10b981; }
|
||||
.detail-section-content {
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.detail-section-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.detail-section-content pre {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px 15px;
|
||||
}
|
||||
.detail-grid dt {
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
.detail-grid dd {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 차트 영역 */
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.chart-bar {
|
||||
flex: 1;
|
||||
background: linear-gradient(to top, #10b981, #34d399);
|
||||
border-radius: 4px 4px 0 0;
|
||||
min-height: 10px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.chart-bar:hover {
|
||||
transform: scaleY(1.05);
|
||||
transform-origin: bottom;
|
||||
}
|
||||
.chart-bar .tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1f2937;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.chart-bar:hover .tooltip { opacity: 1; }
|
||||
.chart-labels {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.chart-labels span {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* 로딩 */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 15px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* 빈 상태 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.empty-state .icon { font-size: 3rem; margin-bottom: 15px; }
|
||||
|
||||
/* 반응형 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
.log-table {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.log-table th, .log-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="header">
|
||||
<h1>🤖 PAAI 어드민</h1>
|
||||
<nav class="nav-links">
|
||||
<a href="/pmr" class="active">← 조제관리</a>
|
||||
<a href="#" onclick="refreshData()">🔄 새로고침</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="container">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="stats-grid" id="statsGrid">
|
||||
<div class="stat-card">
|
||||
<div class="icon blue">📊</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statTotal">-</div>
|
||||
<div class="label">총 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon green">📅</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statToday">-</div>
|
||||
<div class="label">오늘</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon purple">👍</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statUseful">-</div>
|
||||
<div class="label">유용 평가율</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon yellow">⚠️</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statSevere">-</div>
|
||||
<div class="label">KIMS 경고 (오늘)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="icon blue">⏱️</div>
|
||||
<div class="info">
|
||||
<div class="value" id="statAvgTime">-</div>
|
||||
<div class="label">평균 응답시간</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 일별 통계 차트 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>📈 일별 분석 추이 (최근 14일)</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="chart-container" id="dailyChart"></div>
|
||||
<div class="chart-labels" id="chartLabels"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 분석 이력 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h2>📋 분석 이력</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<!-- 필터 -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<label>날짜:</label>
|
||||
<input type="date" id="filterDate">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>환자명:</label>
|
||||
<input type="text" id="filterPatient" placeholder="검색...">
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>상태:</label>
|
||||
<select id="filterStatus">
|
||||
<option value="">전체</option>
|
||||
<option value="success">성공</option>
|
||||
<option value="error">에러</option>
|
||||
<option value="pending">대기중</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>KIMS 경고:</label>
|
||||
<select id="filterSevere">
|
||||
<option value="">전체</option>
|
||||
<option value="true">있음</option>
|
||||
<option value="false">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="loadLogs()">검색</button>
|
||||
<button class="btn btn-secondary" onclick="clearFilters()">초기화</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div id="logsContainer">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<div>로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 모달 -->
|
||||
<div class="modal" id="detailModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="modalTitle">📋 분석 상세</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 초기화
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStats();
|
||||
loadDailyStats();
|
||||
loadLogs();
|
||||
});
|
||||
|
||||
// 통계 로드
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/pmr/api/admin/stats');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
const s = data.stats;
|
||||
document.getElementById('statTotal').textContent = s.total.toLocaleString();
|
||||
document.getElementById('statToday').textContent = s.today;
|
||||
document.getElementById('statSevere').textContent = s.severe_count;
|
||||
document.getElementById('statAvgTime').textContent = (s.avg_response_time / 1000).toFixed(1) + '초';
|
||||
|
||||
if (s.feedback && s.feedback.total > 0) {
|
||||
document.getElementById('statUseful').textContent = s.feedback.rate + '%';
|
||||
} else {
|
||||
document.getElementById('statUseful').textContent = '-';
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Stats error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 일별 통계 로드
|
||||
async function loadDailyStats() {
|
||||
try {
|
||||
const res = await fetch('/pmr/api/admin/feedback-stats');
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success && data.stats.length > 0) {
|
||||
renderChart(data.stats.slice(0, 14).reverse());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Daily stats error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 렌더링
|
||||
function renderChart(stats) {
|
||||
const container = document.getElementById('dailyChart');
|
||||
const labels = document.getElementById('chartLabels');
|
||||
|
||||
const maxTotal = Math.max(...stats.map(s => s.total), 1);
|
||||
|
||||
container.innerHTML = stats.map(s => {
|
||||
const height = Math.max((s.total / maxTotal) * 100, 5);
|
||||
const usefulPct = s.total > 0 ? Math.round((s.useful / s.total) * 100) : 0;
|
||||
return `
|
||||
<div class="chart-bar" style="height: ${height}%">
|
||||
<div class="tooltip">
|
||||
${s.date.slice(5)}<br>
|
||||
분석: ${s.total}건<br>
|
||||
유용: ${usefulPct}%<br>
|
||||
경고: ${s.severe}건
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
labels.innerHTML = stats.map(s => `<span>${s.date.slice(5)}</span>`).join('');
|
||||
}
|
||||
|
||||
// 로그 로드
|
||||
async function loadLogs() {
|
||||
const container = document.getElementById('logsContainer');
|
||||
container.innerHTML = '<div class="loading"><div class="spinner"></div><div>로딩 중...</div></div>';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const date = document.getElementById('filterDate').value;
|
||||
const patient = document.getElementById('filterPatient').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const severe = document.getElementById('filterSevere').value;
|
||||
|
||||
if (date) params.append('date', date);
|
||||
if (patient) params.append('patient_name', patient);
|
||||
if (status) params.append('status', status);
|
||||
if (severe) params.append('has_severe', severe);
|
||||
params.append('limit', '100');
|
||||
|
||||
const res = await fetch('/pmr/api/admin/logs?' + params.toString());
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderLogs(data.logs);
|
||||
} else {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>로드 실패</div></div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Logs error:', err);
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div>오류 발생</div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 로그 테이블 렌더링
|
||||
function renderLogs(logs) {
|
||||
const container = document.getElementById('logsContainer');
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div>분석 이력이 없습니다</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>일시</th>
|
||||
<th>환자</th>
|
||||
<th>약품수</th>
|
||||
<th>KIMS</th>
|
||||
<th>상태</th>
|
||||
<th>피드백</th>
|
||||
<th>응답시간</th>
|
||||
<th>상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${logs.map(log => {
|
||||
const date = new Date(log.created_at);
|
||||
const dateStr = date.toLocaleString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const statusBadge = {
|
||||
'success': '<span class="badge badge-success">성공</span>',
|
||||
'error': '<span class="badge badge-error">에러</span>',
|
||||
'pending': '<span class="badge badge-pending">대기</span>',
|
||||
'kims_done': '<span class="badge badge-pending">AI 대기</span>'
|
||||
}[log.status] || log.status;
|
||||
|
||||
let feedbackBadge = '<span class="badge badge-no-feedback">-</span>';
|
||||
if (log.feedback_useful === 1) {
|
||||
feedbackBadge = '<span class="badge badge-useful">👍</span>';
|
||||
} else if (log.feedback_useful === 0) {
|
||||
feedbackBadge = '<span class="badge badge-not-useful">👎</span>';
|
||||
}
|
||||
|
||||
const kimsInfo = log.kims_has_severe
|
||||
? `<span class="badge badge-severe">⚠️ ${log.kims_interaction_count}건</span>`
|
||||
: (log.kims_interaction_count > 0 ? `${log.kims_interaction_count}건` : '-');
|
||||
|
||||
const responseTime = log.ai_response_time_ms
|
||||
? (log.ai_response_time_ms / 1000).toFixed(1) + '초'
|
||||
: '-';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${log.id}</td>
|
||||
<td>${dateStr}</td>
|
||||
<td>${log.patient_name || '-'}</td>
|
||||
<td>${log.current_med_count || 0}</td>
|
||||
<td>${kimsInfo}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${feedbackBadge}</td>
|
||||
<td>${responseTime}</td>
|
||||
<td class="actions">
|
||||
<button onclick="showDetail(${log.id})">상세</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// 필터 초기화
|
||||
function clearFilters() {
|
||||
document.getElementById('filterDate').value = '';
|
||||
document.getElementById('filterPatient').value = '';
|
||||
document.getElementById('filterStatus').value = '';
|
||||
document.getElementById('filterSevere').value = '';
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// 상세 보기
|
||||
async function showDetail(logId) {
|
||||
const modal = document.getElementById('detailModal');
|
||||
const body = document.getElementById('modalBody');
|
||||
const title = document.getElementById('modalTitle');
|
||||
|
||||
modal.classList.add('show');
|
||||
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/pmr/api/admin/log/${logId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
renderDetail(data.log);
|
||||
} else {
|
||||
body.innerHTML = '<div class="empty-state">로드 실패</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Detail error:', err);
|
||||
body.innerHTML = '<div class="empty-state">오류 발생</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 상세 렌더링
|
||||
function renderDetail(log) {
|
||||
const body = document.getElementById('modalBody');
|
||||
const title = document.getElementById('modalTitle');
|
||||
|
||||
title.textContent = `📋 분석 상세 - ${log.patient_name || '환자'}님 (#${log.id})`;
|
||||
|
||||
// 약품 목록 포맷
|
||||
let medsHtml = '-';
|
||||
if (log.current_medications && log.current_medications.length > 0) {
|
||||
medsHtml = log.current_medications.map(m =>
|
||||
`${m.name || m.code} (${m.dosage || '-'} × ${m.frequency || '-'}회 × ${m.days || '-'}일)`
|
||||
).join('<br>');
|
||||
}
|
||||
|
||||
// 피드백 상태
|
||||
let feedbackHtml = '<span class="badge badge-no-feedback">없음</span>';
|
||||
if (log.feedback_useful === 1) {
|
||||
feedbackHtml = '<span class="badge badge-useful">👍 유용해요</span>';
|
||||
} else if (log.feedback_useful === 0) {
|
||||
feedbackHtml = '<span class="badge badge-not-useful">👎 아니요</span>';
|
||||
}
|
||||
|
||||
body.innerHTML = `
|
||||
<!-- 기본 정보 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ 환자/처방 정보
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<dl class="detail-grid">
|
||||
<dt>처방번호</dt><dd>${log.pre_serial || '-'}</dd>
|
||||
<dt>환자코드</dt><dd>${log.patient_code || '-'}</dd>
|
||||
<dt>환자명</dt><dd>${log.patient_name || '-'}</dd>
|
||||
<dt>질병 1</dt><dd>[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</dd>
|
||||
<dt>질병 2</dt><dd>[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</dd>
|
||||
<dt>약품</dt><dd>${medsHtml}</dd>
|
||||
<dt>분석일시</dt><dd>${log.created_at}</dd>
|
||||
<dt>상태</dt><dd>${log.status}</dd>
|
||||
<dt>피드백</dt><dd>${feedbackHtml}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KIMS 결과 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ KIMS 상호작용 (${log.kims_response_time_ms || 0}ms)
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<p><strong>조회 약품:</strong> ${(log.kims_drug_codes || []).join(', ') || '-'}</p>
|
||||
<p><strong>상호작용:</strong> ${log.kims_interaction_count || 0}건 ${log.kims_has_severe ? '⚠️ 중증 포함' : ''}</p>
|
||||
${log.kims_interactions && log.kims_interactions.length > 0 ? `
|
||||
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 프롬프트 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▶ AI 프롬프트 (클릭하여 펼치기)
|
||||
</div>
|
||||
<div class="detail-section-content collapsed">
|
||||
<pre>${escapeHtml(log.ai_prompt || '없음')}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 응답 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" onclick="toggleSection(this)">
|
||||
▼ AI 응답 (${log.ai_response_time_ms || 0}ms, ${log.ai_model || '-'})
|
||||
</div>
|
||||
<div class="detail-section-content">
|
||||
<pre>${JSON.stringify(log.ai_response, null, 2) || '없음'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${log.error_message ? `
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title" style="color: #dc2626;">
|
||||
⚠️ 에러 메시지
|
||||
</div>
|
||||
<div class="detail-section-content" style="background: #fee2e2;">
|
||||
${escapeHtml(log.error_message)}
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
// 섹션 토글
|
||||
function toggleSection(titleEl) {
|
||||
const content = titleEl.nextElementSibling;
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
|
||||
content.classList.toggle('collapsed');
|
||||
titleEl.textContent = titleEl.textContent.replace(/^[▼▶]/, isCollapsed ? '▼' : '▶');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.remove('show');
|
||||
}
|
||||
|
||||
// 데이터 새로고침
|
||||
function refreshData() {
|
||||
loadStats();
|
||||
loadDailyStats();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
// HTML 이스케이프
|
||||
function escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 모달 외부 클릭 시 닫기
|
||||
document.getElementById('detailModal').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closeModal();
|
||||
});
|
||||
|
||||
// ESC 키로 모달 닫기
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
359
docs/PAAI-SYSTEM.md
Normal file
359
docs/PAAI-SYSTEM.md
Normal file
@ -0,0 +1,359 @@
|
||||
# PAAI (Pharmacist Assistant AI) 시스템
|
||||
|
||||
> 약사를 위한 AI 기반 처방 분석 및 복약지도 보조 시스템
|
||||
|
||||
## 📋 목차
|
||||
1. [시스템 개요](#시스템-개요)
|
||||
2. [구현 현황](#구현-현황)
|
||||
3. [아키텍처](#아키텍처)
|
||||
4. [데이터베이스](#데이터베이스)
|
||||
5. [API 엔드포인트](#api-엔드포인트)
|
||||
6. [어드민 페이지](#어드민-페이지)
|
||||
7. [향후 계획](#향후-계획)
|
||||
|
||||
---
|
||||
|
||||
## 시스템 개요
|
||||
|
||||
### 목적
|
||||
- 처방전 분석 시 KIMS 약물 상호작용 자동 확인
|
||||
- AI 기반 복약지도 포인트 추천
|
||||
- OTC 구매 이력 기반 맞춤 상담 제안
|
||||
- 처방 변화 감지 및 분석
|
||||
|
||||
### 핵심 기능
|
||||
1. **KIMS 상호작용 조회** - 처방 약품 간 상호작용 자동 체크
|
||||
2. **AI 분석** - Clawdbot(Claude) 기반 처방 인사이트 생성
|
||||
3. **처방 비교** - 이전 처방과 현재 처방 변화 분석
|
||||
4. **OTC 연계** - 환자 OTC 구매 이력 기반 추천
|
||||
|
||||
---
|
||||
|
||||
## 구현 현황
|
||||
|
||||
### ✅ 완료된 기능
|
||||
|
||||
#### PMR (조제관리) 페이지
|
||||
- [x] 환자 목록 / 처방 상세 조회
|
||||
- [x] 이전 처방 비교 모드 (추가/변경/중단/동일 표시)
|
||||
- [x] OTC 구매 이력 모달
|
||||
- [x] PAAI 분석 버튼
|
||||
|
||||
#### PAAI 분석 기능
|
||||
- [x] KIMS API 연동 (약물 상호작용 조회)
|
||||
- [x] Clawdbot Gateway 연동 (AI 분석)
|
||||
- [x] 비동기 토스트 알림 (다른 환자 보면서도 알림 수신)
|
||||
- [x] 분석 결과 캐싱 (환자별)
|
||||
- [x] 피드백 수집 (유용/비유용)
|
||||
|
||||
#### 토스트 알림 시스템
|
||||
- [x] 우상단 오버레이 토스트
|
||||
- [x] A환자 분석 중 → B환자 조회 가능
|
||||
- [x] 토스트 클릭 시 해당 환자 결과 모달
|
||||
|
||||
### 🚧 진행 중
|
||||
|
||||
#### 어드민 페이지
|
||||
- [ ] 피드백 통계 대시보드
|
||||
- [ ] 분석 이력 검색
|
||||
- [ ] KIMS 호출 로그
|
||||
- [ ] AI 요청/응답 로그
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PMR 페이지 (pmr.html) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ 환자 목록 │ │ 처방 상세 │ │ PAAI 토스트/모달 │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Flask API (pmr_api.py) │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ /pmr/api/ │ │ /pmr/api/ │ │ /pmr/api/paai/ │ │
|
||||
│ │ prescriptions│ │ patient/ │ │ analyze, feedback │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ PIT3000 DB │ │ KIMS API │ │ Clawdbot Gateway│
|
||||
│ (MSSQL) │ │ (상호작용) │ │ (Claude AI) │
|
||||
│ │ │ │ │ │
|
||||
│ - PM_PRES │ │ - 약품 검색 │ │ - WebSocket │
|
||||
│ - PM_DRUG │ │ - 상호작용 조회 │ │ - 세션 관리 │
|
||||
│ - PM_CUS │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ SQLite DB │
|
||||
│ (paai_logs) │
|
||||
│ │
|
||||
│ - 분석 로그 │
|
||||
│ - 피드백 │
|
||||
│ - KIMS 로그 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
### paai_logs 테이블 (SQLite)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS paai_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 환자/처방 정보
|
||||
pre_serial TEXT, -- 처방전 번호
|
||||
cus_code TEXT, -- 환자 코드
|
||||
patient_name TEXT, -- 환자명
|
||||
|
||||
-- 질병 정보
|
||||
disease_codes TEXT, -- JSON: ["M750", "K299"]
|
||||
disease_names TEXT, -- JSON: ["어깨 유착성 관절낭염", "위십이지장염"]
|
||||
|
||||
-- 처방 정보
|
||||
medication_count INTEGER, -- 약품 수
|
||||
medications_json TEXT, -- JSON: 전체 약품 리스트
|
||||
|
||||
-- KIMS 결과
|
||||
kims_called BOOLEAN, -- KIMS 호출 여부
|
||||
kims_request_json TEXT, -- KIMS 요청 데이터
|
||||
kims_response_json TEXT, -- KIMS 응답 원본
|
||||
kims_interaction_count INTEGER, -- 상호작용 건수
|
||||
kims_has_severe BOOLEAN, -- 중증 상호작용 여부
|
||||
kims_duration_ms INTEGER, -- KIMS 응답 시간
|
||||
|
||||
-- AI 분석 결과
|
||||
ai_called BOOLEAN, -- AI 호출 여부
|
||||
ai_prompt_json TEXT, -- AI에게 전달한 프롬프트
|
||||
ai_response_json TEXT, -- AI 응답 원본
|
||||
ai_parsed_json TEXT, -- 파싱된 분석 결과
|
||||
ai_duration_ms INTEGER, -- AI 응답 시간
|
||||
ai_model TEXT, -- 사용 모델 (claude-opus-4-5 등)
|
||||
|
||||
-- 피드백
|
||||
feedback_useful BOOLEAN, -- 유용했는지
|
||||
feedback_at TIMESTAMP, -- 피드백 시간
|
||||
feedback_comment TEXT, -- 추가 코멘트 (향후)
|
||||
|
||||
-- 메타
|
||||
total_duration_ms INTEGER, -- 전체 처리 시간
|
||||
error_message TEXT, -- 에러 발생 시
|
||||
client_ip TEXT -- 요청 IP
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX idx_paai_created ON paai_logs(created_at);
|
||||
CREATE INDEX idx_paai_patient ON paai_logs(cus_code);
|
||||
CREATE INDEX idx_paai_feedback ON paai_logs(feedback_useful);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 엔드포인트
|
||||
|
||||
### PAAI 분석
|
||||
|
||||
#### POST `/pmr/api/paai/analyze`
|
||||
|
||||
**요청:**
|
||||
```json
|
||||
{
|
||||
"pre_serial": "20260305001",
|
||||
"cus_code": "C00123",
|
||||
"patient_name": "김미성",
|
||||
"disease_info": {
|
||||
"code_1": "M750",
|
||||
"name_1": "어깨의 유착성 관절낭염",
|
||||
"code_2": "K299",
|
||||
"name_2": "상세불명의 위십이지장염"
|
||||
},
|
||||
"current_medications": [
|
||||
{"code": "641500020", "name": "아세탑정", "dosage": "1", "frequency": "2", "days": "5"}
|
||||
],
|
||||
"previous_medications": [],
|
||||
"otc_history": {
|
||||
"visit_count": 5,
|
||||
"frequent_items": [{"name": "신신파스", "count": 3}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**응답:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"log_id": 42,
|
||||
"kims_summary": {
|
||||
"interaction_count": 2,
|
||||
"has_severe": false,
|
||||
"interactions": [...]
|
||||
},
|
||||
"analysis": {
|
||||
"prescription_insight": "소염진통제와 위장약 병용 처방...",
|
||||
"kims_analysis": "아세클로페낙과 레바미피드 병용은...",
|
||||
"cautions": ["식후 30분 복용", "위장장애 주의"],
|
||||
"otc_recommendations": [
|
||||
{"product": "신신파스", "reason": "근골격계 통증 보조"}
|
||||
],
|
||||
"counseling_points": ["충분한 수분 섭취", "알코올 자제"]
|
||||
},
|
||||
"timing": {
|
||||
"kims_ms": 234,
|
||||
"ai_ms": 2891,
|
||||
"total_ms": 3125
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 피드백
|
||||
|
||||
#### POST `/pmr/api/paai/feedback`
|
||||
|
||||
**요청:**
|
||||
```json
|
||||
{
|
||||
"log_id": 42,
|
||||
"useful": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 어드민 페이지
|
||||
|
||||
### 📊 대시보드 (`/pmr/admin`)
|
||||
|
||||
#### 1. 개요 통계
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📊 PAAI 어드민 대시보드 [날짜 선택] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ 127 │ │ 89% │ │ 15건 │ │ 2.3초 │ │
|
||||
│ │ 총 분석 │ │ 유용 평가│ │KIMS 경고 │ │ 평균응답 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 2. 피드백 통계
|
||||
- 일별/주별/월별 유용/비유용 비율 차트
|
||||
- 비유용 피드백 많은 케이스 분석
|
||||
|
||||
#### 3. 분석 이력 검색
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 🔍 분석 이력 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 환자명: [_________] 기간: [____] ~ [____] [검색] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ # │ 일시 │ 환자 │ 약품수│ KIMS │ 피드백│ 상세 │
|
||||
│ ───┼────────────┼─────────┼───────┼──────┼───────┼─────── │
|
||||
│ 1 │ 03-05 14:32│ 김미성 │ 4 │ 2건 │ 👍 │ [보기] │
|
||||
│ 2 │ 03-05 14:28│ 박철수 │ 6 │ 0건 │ 👎 │ [보기] │
|
||||
│ 3 │ 03-05 14:15│ 이영희 │ 3 │ 1건 │ - │ [보기] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 4. 상세 로그 보기 (모달)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 📋 분석 상세 - 김미성님 (2026-03-05 14:32) [닫기] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ▼ 환자/처방 정보 │
|
||||
│ 처방번호: 20260305001 │
|
||||
│ 질병: [M750] 어깨 유착성 관절낭염, [K299] 위십이지장염 │
|
||||
│ 약품: 아세탑정, 에페솔정, 레바미피드정, 브로나제정 │
|
||||
│ │
|
||||
│ ▼ KIMS 호출 (234ms) │
|
||||
│ 요청: {"medications": ["641500020", "645678901", ...]} │
|
||||
│ 응답: {"interactions": [...], "count": 2} │
|
||||
│ │
|
||||
│ ▼ AI 프롬프트 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 당신은 약사를 보조하는 AI입니다... │ │
|
||||
│ │ ## 환자 질병 │ │
|
||||
│ │ [M750] 어깨의 유착성 관절낭염... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ▼ AI 응답 (2891ms) │
|
||||
│ 모델: claude-opus-4-5 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ { │ │
|
||||
│ │ "prescription_insight": "소염진통제와...", │ │
|
||||
│ │ "kims_analysis": "아세클로페낙과...", │ │
|
||||
│ │ "cautions": ["식후 30분 복용", ...], │ │
|
||||
│ │ ... │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ▼ 피드백 │
|
||||
│ 평가: 👍 유용해요 │
|
||||
│ 시간: 2026-03-05 14:35:21 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 5. KIMS 호출 로그
|
||||
- 일별 KIMS API 호출 횟수
|
||||
- 상호작용 감지율
|
||||
- 중증 경고 발생 케이스
|
||||
|
||||
#### 6. AI 성능 모니터링
|
||||
- 평균 응답 시간 추이
|
||||
- 에러율
|
||||
- 모델별 사용량
|
||||
|
||||
---
|
||||
|
||||
## 향후 계획
|
||||
|
||||
### Phase 1 (현재)
|
||||
- [x] 기본 PAAI 분석 기능
|
||||
- [x] 비동기 토스트 알림
|
||||
- [ ] 어드민 페이지 기본
|
||||
|
||||
### Phase 2
|
||||
- [ ] 피드백 기반 프롬프트 개선
|
||||
- [ ] 자주 나오는 상담 포인트 학습
|
||||
- [ ] 약국별 맞춤 설정
|
||||
|
||||
### Phase 3
|
||||
- [ ] 다중 약국 지원
|
||||
- [ ] 분석 결과 PDF 출력
|
||||
- [ ] 환자용 복약지도 문자 발송 연동
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
backend/
|
||||
├── pmr_api.py # Flask API 서버
|
||||
├── services/
|
||||
│ ├── kims_service.py # KIMS API 연동
|
||||
│ └── clawdbot_client.py # Clawdbot Gateway 연동
|
||||
├── templates/
|
||||
│ ├── pmr.html # 조제관리 페이지
|
||||
│ └── pmr_admin.html # 어드민 페이지 (예정)
|
||||
└── db/
|
||||
└── paai_logs.db # PAAI 로그 SQLite
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*최종 업데이트: 2026-03-05*
|
||||
Loading…
Reference in New Issue
Block a user