- PAAI 로그 테이블 스키마 (paai_logs_schema.sql) - PAAI 로거 모듈 (db/paai_logger.py) - /pmr/api/paai/analyze API 엔드포인트 - KIMS API 연동 (KD코드 기반 상호작용 조회) - Clawdbot AI 연동 (HTTP API) - PMR 화면 PAAI 버튼 및 모달 - Admin 페이지 (/admin/paai) - 피드백 수집 기능
494 lines
19 KiB
HTML
494 lines
19 KiB
HTML
<!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;
|
||
}
|
||
.header h1 { font-size: 1.5rem; }
|
||
.header a { color: #fff; text-decoration: none; opacity: 0.8; }
|
||
.header a:hover { opacity: 1; }
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
/* 통계 카드 */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 25px;
|
||
}
|
||
.stat-card {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||
}
|
||
.stat-card .num { font-size: 2rem; font-weight: 700; color: #10b981; }
|
||
.stat-card .label { font-size: 0.85rem; color: #6b7280; margin-top: 5px; }
|
||
.stat-card.severe .num { color: #ef4444; }
|
||
|
||
/* 필터 */
|
||
.filters {
|
||
background: #fff;
|
||
padding: 15px 20px;
|
||
border-radius: 12px;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||
}
|
||
.filters input, .filters select {
|
||
padding: 8px 12px;
|
||
border: 2px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
font-size: 0.9rem;
|
||
}
|
||
.filters input:focus, .filters select:focus {
|
||
outline: none;
|
||
border-color: #10b981;
|
||
}
|
||
.filters button {
|
||
padding: 8px 20px;
|
||
background: #10b981;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
}
|
||
.filters button:hover { background: #059669; }
|
||
|
||
/* 로그 테이블 */
|
||
.log-table {
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||
}
|
||
.log-table 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;
|
||
}
|
||
.log-table td {
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
font-size: 0.9rem;
|
||
}
|
||
.log-table tr:hover { background: #f9fafb; cursor: pointer; }
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 3px 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-caution { background: #fef3c7; color: #d97706; }
|
||
|
||
.feedback-icon { font-size: 1.1rem; }
|
||
|
||
/* 상세 모달 */
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
padding: 20px;
|
||
}
|
||
.modal.show { display: flex; }
|
||
.modal-content {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
width: 100%;
|
||
max-width: 900px;
|
||
max-height: 90vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.modal-header {
|
||
background: linear-gradient(135deg, #10b981, #059669);
|
||
color: #fff;
|
||
padding: 20px 25px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.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-body {
|
||
padding: 25px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
.detail-section {
|
||
margin-bottom: 20px;
|
||
}
|
||
.detail-section h4 {
|
||
font-size: 0.95rem;
|
||
color: #374151;
|
||
margin-bottom: 10px;
|
||
border-bottom: 2px solid #10b981;
|
||
padding-bottom: 5px;
|
||
}
|
||
.detail-section pre {
|
||
background: #f9fafb;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
font-size: 0.85rem;
|
||
overflow-x: auto;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
.detail-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.detail-item {
|
||
background: #f9fafb;
|
||
padding: 10px 15px;
|
||
border-radius: 8px;
|
||
}
|
||
.detail-item .label { font-size: 0.8rem; color: #6b7280; }
|
||
.detail-item .value { font-weight: 600; color: #111827; }
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 60px;
|
||
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); } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>🤖 PAAI 분석 로그</h1>
|
||
<a href="/admin">← 관리자 홈</a>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<!-- 통계 -->
|
||
<div class="stats-grid" id="statsGrid">
|
||
<div class="stat-card">
|
||
<div class="num" id="statTotal">-</div>
|
||
<div class="label">전체</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="statToday">-</div>
|
||
<div class="label">오늘</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="statSuccessRate">-</div>
|
||
<div class="label">성공률</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="statAvgTime">-</div>
|
||
<div class="label">평균 응답(ms)</div>
|
||
</div>
|
||
<div class="stat-card severe">
|
||
<div class="num" id="statSevere">-</div>
|
||
<div class="label">심각 상호작용</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="statFeedback">-</div>
|
||
<div class="label">유용 피드백</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 필터 -->
|
||
<div class="filters">
|
||
<input type="date" id="filterDate" placeholder="날짜">
|
||
<select id="filterStatus">
|
||
<option value="">상태: 전체</option>
|
||
<option value="success">성공</option>
|
||
<option value="error">오류</option>
|
||
<option value="pending">대기중</option>
|
||
</select>
|
||
<select id="filterSevere">
|
||
<option value="">상호작용: 전체</option>
|
||
<option value="true">심각 있음</option>
|
||
<option value="false">심각 없음</option>
|
||
</select>
|
||
<button onclick="loadLogs()">🔍 조회</button>
|
||
<button onclick="loadLogs()" style="background:#6b7280;">🔄 새로고침</button>
|
||
</div>
|
||
|
||
<!-- 로그 테이블 -->
|
||
<div class="log-table">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>시간</th>
|
||
<th>환자</th>
|
||
<th>처방번호</th>
|
||
<th>약품수</th>
|
||
<th>KIMS</th>
|
||
<th>상태</th>
|
||
<th>응답시간</th>
|
||
<th>피드백</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="logTableBody">
|
||
<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 상세 모달 -->
|
||
<div class="modal" id="detailModal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>📋 분석 상세</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();
|
||
loadLogs();
|
||
});
|
||
|
||
// 통계 로드
|
||
async function loadStats() {
|
||
try {
|
||
const res = await fetch('/api/paai/logs/stats');
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const s = data.stats;
|
||
document.getElementById('statTotal').textContent = s.total;
|
||
document.getElementById('statToday').textContent = s.today;
|
||
document.getElementById('statSuccessRate').textContent = s.success_rate + '%';
|
||
document.getElementById('statAvgTime').textContent = s.avg_response_time;
|
||
document.getElementById('statSevere').textContent = s.severe_count;
|
||
document.getElementById('statFeedback').textContent =
|
||
s.feedback ? `${s.feedback.useful}/${s.feedback.total}` : '0/0';
|
||
}
|
||
} catch (err) {
|
||
console.error('Stats error:', err);
|
||
}
|
||
}
|
||
|
||
// 로그 로드
|
||
async function loadLogs() {
|
||
const tbody = document.getElementById('logTableBody');
|
||
tbody.innerHTML = '<tr><td colspan="8" class="loading"><div class="spinner"></div>로딩 중...</td></tr>';
|
||
|
||
try {
|
||
const date = document.getElementById('filterDate').value;
|
||
const status = document.getElementById('filterStatus').value;
|
||
const severe = document.getElementById('filterSevere').value;
|
||
|
||
const params = new URLSearchParams();
|
||
if (date) params.append('date', date);
|
||
if (status) params.append('status', status);
|
||
if (severe) params.append('has_severe', severe);
|
||
params.append('limit', '100');
|
||
|
||
const res = await fetch(`/api/paai/logs?${params}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
if (data.logs.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:40px;">로그가 없습니다.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.logs.map(log => {
|
||
const time = new Date(log.created_at).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;
|
||
|
||
const kimsBadge = log.kims_has_severe
|
||
? `<span class="badge badge-severe">🔴 ${log.kims_interaction_count}건</span>`
|
||
: log.kims_interaction_count > 0
|
||
? `<span class="badge badge-caution">⚠️ ${log.kims_interaction_count}건</span>`
|
||
: '<span style="color:#9ca3af;">-</span>';
|
||
|
||
const feedback = log.feedback_useful === 1 ? '👍'
|
||
: log.feedback_useful === 0 ? '👎' : '-';
|
||
|
||
return `
|
||
<tr onclick="showDetail(${log.id})">
|
||
<td>${time}</td>
|
||
<td>${log.patient_name || '-'}</td>
|
||
<td>${log.pre_serial || '-'}</td>
|
||
<td>${log.current_med_count || 0}종</td>
|
||
<td>${kimsBadge}</td>
|
||
<td>${statusBadge}</td>
|
||
<td>${log.ai_response_time_ms || '-'}ms</td>
|
||
<td class="feedback-icon">${feedback}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
}
|
||
} catch (err) {
|
||
console.error('Logs error:', err);
|
||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:#ef4444;">로드 실패</td></tr>';
|
||
}
|
||
}
|
||
|
||
// 상세 보기
|
||
async function showDetail(logId) {
|
||
const modal = document.getElementById('detailModal');
|
||
const body = document.getElementById('modalBody');
|
||
|
||
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
||
modal.classList.add('show');
|
||
|
||
try {
|
||
const res = await fetch(`/api/paai/logs/${logId}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const log = data.log;
|
||
|
||
body.innerHTML = `
|
||
<div class="detail-section">
|
||
<h4>📌 기본 정보</h4>
|
||
<div class="detail-grid">
|
||
<div class="detail-item">
|
||
<div class="label">환자</div>
|
||
<div class="value">${log.patient_name || '-'} (${log.patient_code || '-'})</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">처방번호</div>
|
||
<div class="value">${log.pre_serial || '-'}</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">질병1</div>
|
||
<div class="value">[${log.disease_code_1 || '-'}] ${log.disease_name_1 || '-'}</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">질병2</div>
|
||
<div class="value">[${log.disease_code_2 || '-'}] ${log.disease_name_2 || '-'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h4>💊 현재 처방 (${log.current_med_count || 0}종)</h4>
|
||
<pre>${JSON.stringify(log.current_medications, null, 2)}</pre>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h4>⚠️ KIMS 상호작용 (${log.kims_interaction_count || 0}건)</h4>
|
||
<pre>${JSON.stringify(log.kims_interactions, null, 2)}</pre>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h4>🤖 AI 분석 결과</h4>
|
||
<pre>${JSON.stringify(log.ai_response, null, 2)}</pre>
|
||
</div>
|
||
|
||
<div class="detail-section">
|
||
<h4>📊 성능</h4>
|
||
<div class="detail-grid">
|
||
<div class="detail-item">
|
||
<div class="label">KIMS 응답</div>
|
||
<div class="value">${log.kims_response_time_ms || '-'}ms</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">AI 응답</div>
|
||
<div class="value">${log.ai_response_time_ms || '-'}ms</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">상태</div>
|
||
<div class="value">${log.status}</div>
|
||
</div>
|
||
<div class="detail-item">
|
||
<div class="label">피드백</div>
|
||
<div class="value">${log.feedback_useful === 1 ? '👍 유용' : log.feedback_useful === 0 ? '👎 아님' : '미응답'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (err) {
|
||
console.error('Detail error:', err);
|
||
body.innerHTML = '<div style="text-align:center;color:#ef4444;">로드 실패</div>';
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('detailModal').classList.remove('show');
|
||
}
|
||
|
||
// ESC로 모달 닫기
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') closeModal();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html> |