feat: PAAI (Pharmacist Assistant AI) 기능 구현
- 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) - 피드백 수집 기능
This commit is contained in:
parent
141b211f07
commit
1b33f82fd4
@ -6805,6 +6805,65 @@ def api_animal_drug_info_preview():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# PAAI (Pharmacist Assistant AI) Admin 라우트
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@app.route('/admin/paai')
|
||||
def admin_paai():
|
||||
"""PAAI 분석 로그 관리 페이지"""
|
||||
return render_template('admin_paai.html')
|
||||
|
||||
|
||||
@app.route('/api/paai/logs')
|
||||
def api_paai_logs():
|
||||
"""PAAI 로그 목록 조회"""
|
||||
from db.paai_logger import get_recent_logs
|
||||
|
||||
limit = int(request.args.get('limit', 100))
|
||||
status = request.args.get('status', '')
|
||||
has_severe = request.args.get('has_severe', '')
|
||||
date = request.args.get('date', '')
|
||||
|
||||
try:
|
||||
logs = get_recent_logs(
|
||||
limit=limit,
|
||||
status=status if status else None,
|
||||
has_severe=True if has_severe == 'true' else (False if has_severe == 'false' else None),
|
||||
date=date if date else None
|
||||
)
|
||||
return jsonify({'success': True, 'logs': logs, 'count': len(logs)})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/paai/logs/stats')
|
||||
def api_paai_logs_stats():
|
||||
"""PAAI 로그 통계"""
|
||||
from db.paai_logger import get_stats
|
||||
|
||||
try:
|
||||
stats = get_stats()
|
||||
return jsonify({'success': True, 'stats': stats})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/paai/logs/<int:log_id>')
|
||||
def api_paai_log_detail(log_id):
|
||||
"""PAAI 로그 상세 조회"""
|
||||
from db.paai_logger import get_log_detail
|
||||
|
||||
try:
|
||||
log = get_log_detail(log_id)
|
||||
if log:
|
||||
return jsonify({'success': True, 'log': log})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다.'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
|
||||
|
||||
351
backend/db/paai_logger.py
Normal file
351
backend/db/paai_logger.py
Normal file
@ -0,0 +1,351 @@
|
||||
"""
|
||||
PAAI (Pharmacist Assistant AI) 로깅 모듈
|
||||
- API 호출/응답 SQLite 저장
|
||||
- 분석 결과 및 피드백 관리
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# DB 파일 경로
|
||||
DB_PATH = Path(__file__).parent / 'paai_logs.db'
|
||||
|
||||
|
||||
def init_db():
|
||||
"""DB 초기화 (테이블 생성)"""
|
||||
schema_path = Path(__file__).parent / 'paai_logs_schema.sql'
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||
schema = f.read()
|
||||
cursor.executescript(schema)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"PAAI 로그 DB 초기화 완료: {DB_PATH}")
|
||||
|
||||
|
||||
def create_log(
|
||||
pre_serial: str = None,
|
||||
patient_code: str = None,
|
||||
patient_name: str = None,
|
||||
disease_code_1: str = None,
|
||||
disease_name_1: str = None,
|
||||
disease_code_2: str = None,
|
||||
disease_name_2: str = None,
|
||||
current_medications: list = None,
|
||||
previous_serial: str = None,
|
||||
previous_medications: list = None,
|
||||
prescription_changes: dict = None,
|
||||
otc_history: dict = None
|
||||
) -> int:
|
||||
"""
|
||||
PAAI 분석 로그 생성 (초기 상태)
|
||||
|
||||
Returns:
|
||||
log_id: 생성된 로그 ID
|
||||
"""
|
||||
if not DB_PATH.exists():
|
||||
init_db()
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
current_medications = current_medications or []
|
||||
previous_medications = previous_medications or []
|
||||
otc_history = otc_history or {}
|
||||
|
||||
# 환자명 마스킹
|
||||
masked_name = None
|
||||
if patient_name:
|
||||
masked_name = patient_name[0] + '*' * (len(patient_name) - 1) if len(patient_name) > 1 else patient_name
|
||||
|
||||
cursor.execute("""
|
||||
INSERT INTO paai_logs (
|
||||
pre_serial, patient_code, patient_name,
|
||||
disease_code_1, disease_name_1, disease_code_2, disease_name_2,
|
||||
current_medications, current_med_count,
|
||||
previous_serial, previous_medications, prescription_changes,
|
||||
otc_history, otc_visit_count,
|
||||
status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
""", (
|
||||
pre_serial,
|
||||
patient_code,
|
||||
masked_name,
|
||||
disease_code_1,
|
||||
disease_name_1,
|
||||
disease_code_2,
|
||||
disease_name_2,
|
||||
json.dumps(current_medications, ensure_ascii=False),
|
||||
len(current_medications),
|
||||
previous_serial,
|
||||
json.dumps(previous_medications, ensure_ascii=False),
|
||||
json.dumps(prescription_changes, ensure_ascii=False) if prescription_changes else None,
|
||||
json.dumps(otc_history, ensure_ascii=False),
|
||||
otc_history.get('visit_count', 0)
|
||||
))
|
||||
|
||||
log_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return log_id
|
||||
|
||||
|
||||
def update_kims_result(
|
||||
log_id: int,
|
||||
kims_drug_codes: list = None,
|
||||
kims_interactions: list = None,
|
||||
kims_response_time_ms: int = 0
|
||||
):
|
||||
"""KIMS 상호작용 결과 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
kims_drug_codes = kims_drug_codes or []
|
||||
kims_interactions = kims_interactions or []
|
||||
|
||||
# 심각한 상호작용 여부 (severity 1 또는 2)
|
||||
has_severe = any(
|
||||
str(i.get('severity', '5')) in ['1', '2']
|
||||
for i in kims_interactions
|
||||
)
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
kims_drug_codes = ?,
|
||||
kims_drug_count = ?,
|
||||
kims_interactions = ?,
|
||||
kims_interaction_count = ?,
|
||||
kims_has_severe = ?,
|
||||
kims_response_time_ms = ?,
|
||||
status = 'kims_done'
|
||||
WHERE id = ?
|
||||
""", (
|
||||
json.dumps(kims_drug_codes, ensure_ascii=False),
|
||||
len(kims_drug_codes),
|
||||
json.dumps(kims_interactions, ensure_ascii=False),
|
||||
len(kims_interactions),
|
||||
1 if has_severe else 0,
|
||||
kims_response_time_ms,
|
||||
log_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_ai_result(
|
||||
log_id: int,
|
||||
ai_prompt: str = None,
|
||||
ai_model: str = None,
|
||||
ai_response: dict = None,
|
||||
ai_response_time_ms: int = 0,
|
||||
ai_token_count: int = None
|
||||
):
|
||||
"""AI 분석 결과 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
ai_prompt = ?,
|
||||
ai_model = ?,
|
||||
ai_response = ?,
|
||||
ai_response_time_ms = ?,
|
||||
ai_token_count = ?,
|
||||
status = 'success'
|
||||
WHERE id = ?
|
||||
""", (
|
||||
ai_prompt,
|
||||
ai_model,
|
||||
json.dumps(ai_response, ensure_ascii=False) if ai_response else None,
|
||||
ai_response_time_ms,
|
||||
ai_token_count,
|
||||
log_id
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_error(log_id: int, error_message: str):
|
||||
"""에러 상태 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
status = 'error',
|
||||
error_message = ?
|
||||
WHERE id = ?
|
||||
""", (error_message, log_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_feedback(log_id: int, useful: bool, comment: str = None):
|
||||
"""피드백 업데이트"""
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE paai_logs SET
|
||||
feedback_useful = ?,
|
||||
feedback_comment = ?
|
||||
WHERE id = ?
|
||||
""", (1 if useful else 0, comment, log_id))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_logs(
|
||||
limit: int = 100,
|
||||
status: str = None,
|
||||
has_severe: bool = None,
|
||||
date: str = None
|
||||
) -> list:
|
||||
"""최근 로그 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
query = "SELECT * FROM paai_logs WHERE 1=1"
|
||||
params = []
|
||||
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
|
||||
if has_severe is not None:
|
||||
query += " AND kims_has_severe = ?"
|
||||
params.append(1 if has_severe else 0)
|
||||
|
||||
if date:
|
||||
query += " AND DATE(created_at) = ?"
|
||||
params.append(date)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
|
||||
cursor.execute(query, params)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = []
|
||||
for row in rows:
|
||||
log = dict(row)
|
||||
# JSON 필드 파싱
|
||||
for field in ['current_medications', 'previous_medications', 'prescription_changes',
|
||||
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
|
||||
if log.get(field):
|
||||
try:
|
||||
log[field] = json.loads(log[field])
|
||||
except:
|
||||
pass
|
||||
result.append(log)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
def get_log_detail(log_id: int) -> dict:
|
||||
"""로그 상세 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return None
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT * FROM paai_logs WHERE id = ?", (log_id,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
log = dict(row)
|
||||
|
||||
# JSON 필드 파싱
|
||||
for field in ['current_medications', 'previous_medications', 'prescription_changes',
|
||||
'otc_history', 'kims_drug_codes', 'kims_interactions', 'ai_response']:
|
||||
if log.get(field):
|
||||
try:
|
||||
log[field] = json.loads(log[field])
|
||||
except:
|
||||
pass
|
||||
|
||||
conn.close()
|
||||
return log
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
"""통계 조회"""
|
||||
if not DB_PATH.exists():
|
||||
return {
|
||||
'total': 0,
|
||||
'today': 0,
|
||||
'success_rate': 0,
|
||||
'avg_response_time': 0,
|
||||
'severe_count': 0
|
||||
}
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
cursor = conn.cursor()
|
||||
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
# 전체 건수
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs")
|
||||
total = cursor.fetchone()[0]
|
||||
|
||||
# 오늘 건수
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE DATE(created_at) = ?", (today,))
|
||||
today_count = cursor.fetchone()[0]
|
||||
|
||||
# 성공률
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE status = 'success'")
|
||||
success_count = cursor.fetchone()[0]
|
||||
success_rate = (success_count / total * 100) if total > 0 else 0
|
||||
|
||||
# 평균 응답시간
|
||||
cursor.execute("SELECT AVG(ai_response_time_ms) FROM paai_logs WHERE ai_response_time_ms > 0")
|
||||
avg_time = cursor.fetchone()[0] or 0
|
||||
|
||||
# 심각한 상호작용 건수 (오늘)
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM paai_logs
|
||||
WHERE DATE(created_at) = ? AND kims_has_severe = 1
|
||||
""", (today,))
|
||||
severe_count = cursor.fetchone()[0]
|
||||
|
||||
# 피드백 통계
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful = 1")
|
||||
useful_count = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM paai_logs WHERE feedback_useful IS NOT NULL")
|
||||
feedback_total = cursor.fetchone()[0]
|
||||
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'today': today_count,
|
||||
'success_rate': round(success_rate, 1),
|
||||
'avg_response_time': int(avg_time),
|
||||
'severe_count': severe_count,
|
||||
'feedback': {
|
||||
'useful': useful_count,
|
||||
'total': feedback_total,
|
||||
'rate': round(useful_count / feedback_total * 100, 1) if feedback_total > 0 else 0
|
||||
}
|
||||
}
|
||||
59
backend/db/paai_logs_schema.sql
Normal file
59
backend/db/paai_logs_schema.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- PAAI (Pharmacist Assistant AI) 로그 스키마
|
||||
-- 생성일: 2026-03-04
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paai_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- 요청 정보
|
||||
pre_serial TEXT, -- 처방번호
|
||||
patient_code TEXT, -- 환자코드 (CusCode)
|
||||
patient_name TEXT, -- 환자명 (마스킹: 김**)
|
||||
|
||||
-- 질병 정보
|
||||
disease_code_1 TEXT, -- St1 (상병코드1)
|
||||
disease_name_1 TEXT, -- 상병명1
|
||||
disease_code_2 TEXT, -- St2 (상병코드2)
|
||||
disease_name_2 TEXT, -- 상병명2
|
||||
|
||||
-- 처방 정보
|
||||
current_medications TEXT, -- JSON: 현재 처방 [{code, name, dosage, ...}]
|
||||
current_med_count INTEGER, -- 현재 처방 약품 수
|
||||
previous_serial TEXT, -- 이전 처방번호
|
||||
previous_medications TEXT, -- JSON: 이전 처방
|
||||
prescription_changes TEXT, -- JSON: {added, removed, changed}
|
||||
|
||||
-- OTC 이력
|
||||
otc_history TEXT, -- JSON: {purchases, frequent_items}
|
||||
otc_visit_count INTEGER, -- OTC 구매 횟수
|
||||
|
||||
-- KIMS 상호작용
|
||||
kims_drug_codes TEXT, -- JSON: 검사한 KD코드 배열
|
||||
kims_drug_count INTEGER, -- 검사한 약품 수
|
||||
kims_interactions TEXT, -- JSON: 상호작용 결과
|
||||
kims_interaction_count INTEGER, -- 상호작용 건수
|
||||
kims_has_severe BOOLEAN DEFAULT 0, -- 심각한 상호작용 (severity 1,2)
|
||||
kims_response_time_ms INTEGER, -- KIMS API 응답시간
|
||||
|
||||
-- AI 분석
|
||||
ai_prompt TEXT, -- AI에 전달한 프롬프트
|
||||
ai_model TEXT, -- 사용된 모델
|
||||
ai_response TEXT, -- JSON: AI 분석 결과
|
||||
ai_response_time_ms INTEGER, -- AI 응답 시간
|
||||
ai_token_count INTEGER, -- 토큰 사용량
|
||||
|
||||
-- 상태
|
||||
status TEXT DEFAULT 'pending', -- pending, kims_done, success, error
|
||||
error_message TEXT,
|
||||
|
||||
-- 피드백
|
||||
feedback_useful INTEGER, -- 1=유용, 0=아님, NULL=미응답
|
||||
feedback_comment TEXT -- 약사 코멘트
|
||||
);
|
||||
|
||||
-- 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_created ON paai_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_patient ON paai_logs(patient_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_status ON paai_logs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_serial ON paai_logs(pre_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_paai_severe ON paai_logs(kims_has_severe);
|
||||
@ -816,3 +816,380 @@ def get_patient_otc_history(cus_code):
|
||||
except Exception as e:
|
||||
logging.error(f"환자 OTC 구매 이력 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# PAAI (Pharmacist Assistant AI) API
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pmr_bp.route('/api/paai/analyze', methods=['POST'])
|
||||
def paai_analyze():
|
||||
"""
|
||||
PAAI 분석 API
|
||||
|
||||
Request:
|
||||
{
|
||||
"pre_serial": "P20260304001",
|
||||
"cus_code": "00001234",
|
||||
"patient_name": "홍길동",
|
||||
"disease_info": {
|
||||
"code_1": "M170", "name_1": "무릎골관절염",
|
||||
"code_2": "K299", "name_2": "상세불명의 위십이지장염"
|
||||
},
|
||||
"current_medications": [
|
||||
{"code": "055101150", "name": "록소프로펜정", "dosage": 1, "frequency": 3, "days": 7}
|
||||
],
|
||||
"previous_serial": "P20260225001",
|
||||
"previous_medications": [...],
|
||||
"otc_history": {...}
|
||||
}
|
||||
"""
|
||||
import requests as http_requests
|
||||
import time as time_module
|
||||
from db.paai_logger import create_log, update_kims_result, update_ai_result, update_error
|
||||
|
||||
start_time = time_module.time()
|
||||
log_id = None
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': '요청 데이터가 없습니다.'}), 400
|
||||
|
||||
pre_serial = data.get('pre_serial')
|
||||
cus_code = data.get('cus_code')
|
||||
patient_name = data.get('patient_name')
|
||||
disease_info = data.get('disease_info', {})
|
||||
current_medications = data.get('current_medications', [])
|
||||
previous_serial = data.get('previous_serial')
|
||||
previous_medications = data.get('previous_medications', [])
|
||||
otc_history = data.get('otc_history', {})
|
||||
|
||||
# 처방 변화 분석
|
||||
prescription_changes = analyze_prescription_changes(
|
||||
current_medications, previous_medications
|
||||
)
|
||||
|
||||
# 1. 로그 생성
|
||||
log_id = create_log(
|
||||
pre_serial=pre_serial,
|
||||
patient_code=cus_code,
|
||||
patient_name=patient_name,
|
||||
disease_code_1=disease_info.get('code_1'),
|
||||
disease_name_1=disease_info.get('name_1'),
|
||||
disease_code_2=disease_info.get('code_2'),
|
||||
disease_name_2=disease_info.get('name_2'),
|
||||
current_medications=current_medications,
|
||||
previous_serial=previous_serial,
|
||||
previous_medications=previous_medications,
|
||||
prescription_changes=prescription_changes,
|
||||
otc_history=otc_history
|
||||
)
|
||||
|
||||
# 2. KD코드 추출 (9자리 KIMS 코드)
|
||||
kd_codes = []
|
||||
drug_names = []
|
||||
for med in current_medications:
|
||||
code = med.get('code', '')
|
||||
if code and len(str(code)) == 9:
|
||||
kd_codes.append(str(code))
|
||||
drug_names.append(med.get('name', ''))
|
||||
|
||||
# 3. KIMS API 호출 (약품 2개 이상인 경우)
|
||||
kims_interactions = []
|
||||
kims_start = time_module.time()
|
||||
|
||||
if len(kd_codes) >= 2:
|
||||
try:
|
||||
kims_url = "https://api2.kims.co.kr/api/interaction/info"
|
||||
kims_headers = {
|
||||
'Authorization': 'Basic VFNQTUtSOg==',
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json; charset=utf-8'
|
||||
}
|
||||
kims_payload = {'KDCodes': kd_codes}
|
||||
|
||||
kims_response = http_requests.get(
|
||||
kims_url,
|
||||
headers=kims_headers,
|
||||
data=__import__('json').dumps(kims_payload),
|
||||
timeout=10,
|
||||
verify=False
|
||||
)
|
||||
|
||||
if kims_response.status_code == 200:
|
||||
kims_data = kims_response.json()
|
||||
if kims_data.get('Message') == 'SUCCESS':
|
||||
kims_interactions = kims_data.get('InteractionList', [])
|
||||
except Exception as kims_err:
|
||||
logging.warning(f"KIMS API 오류 (무시하고 계속): {kims_err}")
|
||||
|
||||
kims_time = int((time_module.time() - kims_start) * 1000)
|
||||
|
||||
# KIMS 결과 로그 업데이트
|
||||
update_kims_result(
|
||||
log_id=log_id,
|
||||
kims_drug_codes=kd_codes,
|
||||
kims_interactions=kims_interactions,
|
||||
kims_response_time_ms=kims_time
|
||||
)
|
||||
|
||||
# 4. AI 프롬프트 생성
|
||||
ai_prompt = build_paai_prompt(
|
||||
disease_info=disease_info,
|
||||
current_medications=current_medications,
|
||||
prescription_changes=prescription_changes,
|
||||
kims_interactions=kims_interactions,
|
||||
otc_history=otc_history
|
||||
)
|
||||
|
||||
# 5. Clawdbot AI 호출 (WebSocket)
|
||||
ai_start = time_module.time()
|
||||
ai_response = call_clawdbot_ai(ai_prompt)
|
||||
ai_time = int((time_module.time() - ai_start) * 1000)
|
||||
|
||||
# AI 결과 로그 업데이트
|
||||
update_ai_result(
|
||||
log_id=log_id,
|
||||
ai_prompt=ai_prompt,
|
||||
ai_model='claude-sonnet-4',
|
||||
ai_response=ai_response,
|
||||
ai_response_time_ms=ai_time
|
||||
)
|
||||
|
||||
total_time = int((time_module.time() - start_time) * 1000)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'log_id': log_id,
|
||||
'analysis': ai_response,
|
||||
'kims_summary': {
|
||||
'drug_count': len(kd_codes),
|
||||
'interaction_count': len(kims_interactions),
|
||||
'has_severe': any(str(i.get('Severity', '5')) in ['1', '2'] for i in kims_interactions)
|
||||
},
|
||||
'timing': {
|
||||
'kims_ms': kims_time,
|
||||
'ai_ms': ai_time,
|
||||
'total_ms': total_time
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 분석 오류: {e}")
|
||||
if log_id:
|
||||
update_error(log_id, str(e))
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
def analyze_prescription_changes(current: list, previous: list) -> dict:
|
||||
"""처방 변화 분석"""
|
||||
current_codes = {m.get('code'): m for m in current}
|
||||
previous_codes = {m.get('code'): m for m in previous}
|
||||
|
||||
added = []
|
||||
removed = []
|
||||
changed = []
|
||||
same = []
|
||||
|
||||
# 추가된 약품
|
||||
for code, med in current_codes.items():
|
||||
if code not in previous_codes:
|
||||
added.append(med)
|
||||
else:
|
||||
# 변경 여부 확인
|
||||
prev_med = previous_codes[code]
|
||||
changes = []
|
||||
for field in ['dosage', 'frequency', 'days']:
|
||||
if med.get(field) != prev_med.get(field):
|
||||
changes.append({
|
||||
'field': field,
|
||||
'from': prev_med.get(field),
|
||||
'to': med.get(field)
|
||||
})
|
||||
if changes:
|
||||
changed.append({'medication': med, 'changes': changes})
|
||||
else:
|
||||
same.append(med)
|
||||
|
||||
# 중단된 약품
|
||||
for code, med in previous_codes.items():
|
||||
if code not in current_codes:
|
||||
removed.append(med)
|
||||
|
||||
return {
|
||||
'added': added,
|
||||
'removed': removed,
|
||||
'changed': changed,
|
||||
'same': same
|
||||
}
|
||||
|
||||
|
||||
def build_paai_prompt(
|
||||
disease_info: dict,
|
||||
current_medications: list,
|
||||
prescription_changes: dict,
|
||||
kims_interactions: list,
|
||||
otc_history: dict
|
||||
) -> str:
|
||||
"""AI 프롬프트 생성"""
|
||||
|
||||
# 질병 정보
|
||||
diseases = []
|
||||
if disease_info.get('code_1'):
|
||||
diseases.append(f"[{disease_info['code_1']}] {disease_info.get('name_1', '')}")
|
||||
if disease_info.get('code_2'):
|
||||
diseases.append(f"[{disease_info['code_2']}] {disease_info.get('name_2', '')}")
|
||||
|
||||
# 현재 처방
|
||||
med_lines = []
|
||||
for med in current_medications:
|
||||
line = f"- {med.get('name', '?')}: {med.get('dosage', 0)}정 × {med.get('frequency', 0)}회 × {med.get('days', 0)}일"
|
||||
med_lines.append(line)
|
||||
|
||||
# 처방 변화
|
||||
change_lines = []
|
||||
if prescription_changes.get('added'):
|
||||
names = [m.get('name', '?') for m in prescription_changes['added']]
|
||||
change_lines.append(f"- 추가: {', '.join(names)}")
|
||||
if prescription_changes.get('removed'):
|
||||
names = [m.get('name', '?') for m in prescription_changes['removed']]
|
||||
change_lines.append(f"- 중단: {', '.join(names)}")
|
||||
if prescription_changes.get('changed'):
|
||||
for item in prescription_changes['changed']:
|
||||
med = item['medication']
|
||||
changes = item['changes']
|
||||
change_desc = ', '.join([f"{c['field']}: {c['from']}→{c['to']}" for c in changes])
|
||||
change_lines.append(f"- 변경: {med.get('name', '?')} ({change_desc})")
|
||||
|
||||
# KIMS 상호작용
|
||||
kims_lines = []
|
||||
for inter in kims_interactions:
|
||||
severity = inter.get('Severity', 5)
|
||||
severity_text = {1: '🔴 금기', 2: '🟠 경고', 3: '🟡 주의', 4: '참고', 5: '정보'}.get(int(severity), '정보')
|
||||
drug1 = inter.get('Drug1Name', '?')
|
||||
drug2 = inter.get('Drug2Name', '?')
|
||||
desc = inter.get('InteractionDesc', '')[:100]
|
||||
kims_lines.append(f"- [{severity_text}] {drug1} + {drug2}: {desc}")
|
||||
|
||||
# OTC 이력
|
||||
otc_lines = []
|
||||
if otc_history.get('frequent_items'):
|
||||
for item in otc_history['frequent_items'][:5]:
|
||||
otc_lines.append(f"- {item.get('name', '?')} ({item.get('count', 0)}회 구매)")
|
||||
|
||||
prompt = f"""당신은 약사를 보조하는 AI입니다. 환자 정보와 KIMS 상호작용 데이터를 바탕으로 분석해주세요.
|
||||
|
||||
## 환자 질병
|
||||
{chr(10).join(diseases) if diseases else '- 정보 없음'}
|
||||
|
||||
## 현재 처방
|
||||
{chr(10).join(med_lines) if med_lines else '- 정보 없음'}
|
||||
|
||||
## 처방 변화 (vs 이전)
|
||||
{chr(10).join(change_lines) if change_lines else '- 변화 없음'}
|
||||
|
||||
## KIMS 약물 상호작용
|
||||
{chr(10).join(kims_lines) if kims_lines else '- 상호작용 없음'}
|
||||
|
||||
## OTC 구매 이력
|
||||
{chr(10).join(otc_lines) if otc_lines else '- 이력 없음'}
|
||||
|
||||
## 요청
|
||||
다음 형식의 JSON으로 간결하게 답변해주세요:
|
||||
{{
|
||||
"prescription_insight": "처방 변화에 대한 분석 (1-2문장)",
|
||||
"kims_analysis": "KIMS 상호작용 해석 및 임상적 의미 (1-2문장)",
|
||||
"cautions": ["복용 주의사항 1", "복용 주의사항 2"],
|
||||
"otc_recommendations": [
|
||||
{{"product": "추천 OTC명", "reason": "추천 이유 (현재 처방과 상호작용 없음 확인)"}}
|
||||
],
|
||||
"counseling_points": ["상담 포인트 1", "상담 포인트 2"]
|
||||
}}
|
||||
"""
|
||||
return prompt
|
||||
|
||||
|
||||
def call_clawdbot_ai(prompt: str) -> dict:
|
||||
"""Clawdbot AI 호출 (HTTP API)"""
|
||||
import requests as http_requests
|
||||
import json
|
||||
|
||||
try:
|
||||
# Clawdbot Gateway API 호출
|
||||
response = http_requests.post(
|
||||
'http://localhost:8765/api/chat',
|
||||
json={
|
||||
'message': prompt,
|
||||
'session': 'paai-analysis',
|
||||
'timeout': 60
|
||||
},
|
||||
timeout=65
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
# AI 응답에서 JSON 파싱 시도
|
||||
ai_text = result.get('response', '')
|
||||
|
||||
# JSON 블록 추출
|
||||
try:
|
||||
import re
|
||||
json_match = re.search(r'\{[\s\S]*\}', ai_text)
|
||||
if json_match:
|
||||
return json.loads(json_match.group())
|
||||
except:
|
||||
pass
|
||||
|
||||
# JSON 파싱 실패 시 텍스트 그대로 반환
|
||||
return {
|
||||
'prescription_insight': ai_text[:200] if ai_text else '분석 결과 없음',
|
||||
'kims_analysis': '',
|
||||
'cautions': [],
|
||||
'otc_recommendations': [],
|
||||
'counseling_points': []
|
||||
}
|
||||
else:
|
||||
raise Exception(f"Clawdbot API 오류: {response.status_code}")
|
||||
|
||||
except http_requests.exceptions.ConnectionError:
|
||||
# Clawdbot 연결 안됨 - 기본 응답 생성
|
||||
return generate_fallback_response(prompt)
|
||||
except Exception as e:
|
||||
logging.error(f"Clawdbot AI 호출 오류: {e}")
|
||||
return generate_fallback_response(prompt)
|
||||
|
||||
|
||||
def generate_fallback_response(prompt: str) -> dict:
|
||||
"""Clawdbot 연결 실패 시 기본 응답"""
|
||||
return {
|
||||
'prescription_insight': 'AI 분석 서비스에 연결할 수 없습니다. KIMS 상호작용 정보를 직접 확인해주세요.',
|
||||
'kims_analysis': '',
|
||||
'cautions': ['AI 분석 불가 - 직접 검토 필요'],
|
||||
'otc_recommendations': [],
|
||||
'counseling_points': [],
|
||||
'_fallback': True
|
||||
}
|
||||
|
||||
|
||||
@pmr_bp.route('/api/paai/feedback', methods=['POST'])
|
||||
def paai_feedback():
|
||||
"""PAAI 피드백 저장"""
|
||||
from db.paai_logger import update_feedback
|
||||
|
||||
try:
|
||||
data = request.get_json()
|
||||
log_id = data.get('log_id')
|
||||
useful = data.get('useful')
|
||||
comment = data.get('comment')
|
||||
|
||||
if not log_id:
|
||||
return jsonify({'success': False, 'error': 'log_id 필요'}), 400
|
||||
|
||||
update_feedback(log_id, useful, comment)
|
||||
|
||||
return jsonify({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PAAI 피드백 저장 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
494
backend/templates/admin_paai.html
Normal file
494
backend/templates/admin_paai.html
Normal file
@ -0,0 +1,494 @@
|
||||
<!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>
|
||||
@ -161,6 +161,167 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* PAAI 버튼 */
|
||||
.detail-header .rx-info .paai-badge {
|
||||
background: linear-gradient(135deg, #10b981, #059669) !important;
|
||||
color: #fff !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
.detail-header .rx-info .paai-badge:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
/* PAAI 모달 */
|
||||
.paai-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1100;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.paai-modal.show { display: flex; }
|
||||
.paai-modal-content {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.paai-modal-header {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: #fff;
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.paai-modal-header h3 { font-size: 1.3rem; }
|
||||
.paai-modal-close {
|
||||
background: rgba(255,255,255,0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.paai-modal-close:hover { background: rgba(255,255,255,0.3); }
|
||||
.paai-modal-body {
|
||||
padding: 25px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.paai-loading {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.paai-loading .spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #10b981;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.paai-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.paai-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #374151;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.paai-section-content {
|
||||
background: #f9fafb;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
color: #4b5563;
|
||||
}
|
||||
.paai-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.paai-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.paai-list li:last-child { border-bottom: none; }
|
||||
.paai-list li::before {
|
||||
content: '•';
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
}
|
||||
.paai-caution {
|
||||
background: #fef3c7 !important;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
.paai-otc-rec {
|
||||
background: #dbeafe !important;
|
||||
padding: 12px 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.paai-otc-rec .product { font-weight: 600; color: #1e40af; }
|
||||
.paai-otc-rec .reason { font-size: 0.9rem; color: #64748b; margin-top: 4px; }
|
||||
.paai-kims-severe {
|
||||
background: #fee2e2 !important;
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
.paai-modal-footer {
|
||||
padding: 15px 25px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.paai-feedback {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.paai-feedback span { font-size: 0.9rem; color: #6b7280; }
|
||||
.paai-feedback button {
|
||||
padding: 8px 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.paai-feedback button:hover { border-color: #10b981; }
|
||||
.paai-feedback button.selected { background: #d1fae5; border-color: #10b981; }
|
||||
.paai-timing {
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* OTC 모달 */
|
||||
.otc-modal {
|
||||
display: none;
|
||||
@ -592,6 +753,31 @@
|
||||
<button class="btn btn-primary" onclick="printLabels()">🖨️ 라벨 인쇄</button>
|
||||
</div>
|
||||
|
||||
<!-- PAAI 분석 모달 -->
|
||||
<div class="paai-modal" id="paaiModal">
|
||||
<div class="paai-modal-content">
|
||||
<div class="paai-modal-header">
|
||||
<h3>🤖 PAAI 분석 결과</h3>
|
||||
<button class="paai-modal-close" onclick="closePaaiModal()">×</button>
|
||||
</div>
|
||||
<div class="paai-modal-body" id="paaiBody">
|
||||
<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>
|
||||
</div>
|
||||
<div class="paai-modal-footer" id="paaiFooter" style="display:none;">
|
||||
<div class="paai-feedback">
|
||||
<span>도움이 되셨나요?</span>
|
||||
<button onclick="sendPaaiFeedback(true)" id="paaiUseful">👍 유용해요</button>
|
||||
<button onclick="sendPaaiFeedback(false)" id="paaiNotUseful">👎 아니요</button>
|
||||
</div>
|
||||
<div class="paai-timing" id="paaiTiming"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OTC 구매 이력 모달 -->
|
||||
<div class="otc-modal" id="otcModal">
|
||||
<div class="otc-modal-content">
|
||||
@ -1096,9 +1282,14 @@
|
||||
} else {
|
||||
otcData = null;
|
||||
}
|
||||
|
||||
// PAAI 버튼 추가 (항상 표시)
|
||||
addPaaiButton();
|
||||
} catch (err) {
|
||||
console.error('OTC check error:', err);
|
||||
otcData = null;
|
||||
// OTC 오류여도 PAAI 버튼은 추가
|
||||
addPaaiButton();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1171,6 +1362,230 @@
|
||||
document.getElementById('otcModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PAAI (Pharmacist Assistant AI) 함수들
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
let currentPaaiLogId = null;
|
||||
|
||||
function addPaaiButton() {
|
||||
const rxInfo = document.getElementById('rxInfo');
|
||||
if (!rxInfo || rxInfo.querySelector('.paai-badge')) return;
|
||||
|
||||
const paaiBtn = document.createElement('span');
|
||||
paaiBtn.className = 'paai-badge';
|
||||
paaiBtn.textContent = '🤖 PAAI 분석';
|
||||
paaiBtn.onclick = showPaaiModal;
|
||||
rxInfo.appendChild(paaiBtn);
|
||||
}
|
||||
|
||||
async function showPaaiModal() {
|
||||
if (!currentPrescription) return;
|
||||
|
||||
const modal = document.getElementById('paaiModal');
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
|
||||
// 초기화
|
||||
body.innerHTML = `
|
||||
<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');
|
||||
|
||||
try {
|
||||
// 요청 데이터 구성
|
||||
const requestData = {
|
||||
pre_serial: currentPrescription.pre_serial,
|
||||
cus_code: currentPrescription.cus_code,
|
||||
patient_name: currentPrescription.name,
|
||||
disease_info: {
|
||||
code_1: currentPrescription.st1 || '',
|
||||
name_1: currentPrescription.st1_name || '',
|
||||
code_2: currentPrescription.st2 || '',
|
||||
name_2: currentPrescription.st2_name || ''
|
||||
},
|
||||
current_medications: (currentPrescription.medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.days
|
||||
})),
|
||||
previous_serial: currentPrescription.previous_serial || '',
|
||||
previous_medications: (currentPrescription.previous_medications || []).map(med => ({
|
||||
code: med.medication_code,
|
||||
name: med.med_name,
|
||||
dosage: med.dosage,
|
||||
frequency: med.frequency,
|
||||
days: med.days
|
||||
})),
|
||||
otc_history: otcData ? {
|
||||
visit_count: otcData.summary?.total_visits || 0,
|
||||
frequent_items: otcData.summary?.frequent_items || [],
|
||||
purchases: otcData.purchases || []
|
||||
} : {}
|
||||
};
|
||||
|
||||
const response = await fetch('/pmr/api/paai/analyze', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
currentPaaiLogId = result.log_id;
|
||||
displayPaaiResult(result);
|
||||
} else {
|
||||
throw new Error(result.error || '분석 실패');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('PAAI error:', err);
|
||||
body.innerHTML = `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function displayPaaiResult(result) {
|
||||
const body = document.getElementById('paaiBody');
|
||||
const footer = document.getElementById('paaiFooter');
|
||||
const timing = document.getElementById('paaiTiming');
|
||||
|
||||
const analysis = result.analysis || {};
|
||||
const kims = result.kims_summary || {};
|
||||
|
||||
let html = '';
|
||||
|
||||
// KIMS 상호작용 요약
|
||||
if (kims.interaction_count > 0) {
|
||||
html += `
|
||||
<div class="paai-section">
|
||||
<div class="paai-section-title">⚠️ KIMS 상호작용 (${kims.interaction_count}건)</div>
|
||||
<div class="paai-section-content ${kims.has_severe ? 'paai-kims-severe' : 'paai-caution'}">
|
||||
${analysis.kims_analysis || 'KIMS 상호작용이 감지되었습니다. 상세 내용을 확인하세요.'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 처방 분석
|
||||
if (analysis.prescription_insight) {
|
||||
html += `
|
||||
<div class="paai-section">
|
||||
<div class="paai-section-title">📋 처방 분석</div>
|
||||
<div class="paai-section-content">${analysis.prescription_insight}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 복용 주의사항
|
||||
if (analysis.cautions && analysis.cautions.length > 0) {
|
||||
html += `
|
||||
<div class="paai-section">
|
||||
<div class="paai-section-title">⚡ 복용 주의사항</div>
|
||||
<div class="paai-section-content paai-caution">
|
||||
<ul class="paai-list">
|
||||
${analysis.cautions.map(c => `<li>${c}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// OTC 추천
|
||||
if (analysis.otc_recommendations && analysis.otc_recommendations.length > 0) {
|
||||
html += `
|
||||
<div class="paai-section">
|
||||
<div class="paai-section-title">💊 OTC 추천</div>
|
||||
<div>
|
||||
${analysis.otc_recommendations.map(rec => `
|
||||
<div class="paai-otc-rec">
|
||||
<div class="product">${rec.product}</div>
|
||||
<div class="reason">${rec.reason}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 상담 포인트
|
||||
if (analysis.counseling_points && analysis.counseling_points.length > 0) {
|
||||
html += `
|
||||
<div class="paai-section">
|
||||
<div class="paai-section-title">💬 상담 포인트</div>
|
||||
<div class="paai-section-content">
|
||||
<ul class="paai-list">
|
||||
${analysis.counseling_points.map(p => `<li>${p}</li>`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// fallback 메시지
|
||||
if (analysis._fallback) {
|
||||
html += `
|
||||
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:0.9rem;">
|
||||
⚠️ AI 서비스 연결 불가 - KIMS 데이터만 표시됨
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
body.innerHTML = html || '<div style="text-align:center;padding:40px;color:#9ca3af;">분석 결과가 없습니다.</div>';
|
||||
|
||||
// 타이밍 정보
|
||||
if (result.timing) {
|
||||
timing.textContent = `KIMS: ${result.timing.kims_ms}ms / AI: ${result.timing.ai_ms}ms / 총: ${result.timing.total_ms}ms`;
|
||||
}
|
||||
|
||||
// 피드백 버튼 초기화
|
||||
document.getElementById('paaiUseful').classList.remove('selected');
|
||||
document.getElementById('paaiNotUseful').classList.remove('selected');
|
||||
|
||||
footer.style.display = 'flex';
|
||||
}
|
||||
|
||||
function closePaaiModal() {
|
||||
document.getElementById('paaiModal').classList.remove('show');
|
||||
}
|
||||
|
||||
async function sendPaaiFeedback(useful) {
|
||||
if (!currentPaaiLogId) return;
|
||||
|
||||
try {
|
||||
await fetch('/pmr/api/paai/feedback', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
log_id: currentPaaiLogId,
|
||||
useful: useful
|
||||
})
|
||||
});
|
||||
|
||||
// 버튼 표시 업데이트
|
||||
document.getElementById('paaiUseful').classList.toggle('selected', useful);
|
||||
document.getElementById('paaiNotUseful').classList.toggle('selected', !useful);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Feedback error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// 상세 초기화
|
||||
function clearDetail() {
|
||||
document.getElementById('detailHeader').style.display = 'none';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user