feat(animal-chat): APC 코드 2024년 체계 지원 및 피부약 2단계 추천
## APC 코드 체계 확장 - 기존: 023%만 검색 (~2023년 제품만) - 변경: 02% OR 92% + 13자리 검증 - 02%: 2023년 이전 item_seq (9자리) 기반 APC - 92%: 2024년 이후 item_seq (10자리) 기반 APC - 999% 등 청구프로그램 임의코드는 제외 ## 동물약 챗봇 피부약 추천 개선 - 피부약 2단계 추천 구조 추가 - 1차(치료): 의약품 (개시딘겔, 테르비덤 등) - 2차(보조케어): 의약외품 (스킨카솔 - 회복기 피부보호) - 스킨카솔은 의약외품임을 명시하여 치료제로 오인 방지 ## 기타 - RAG 테스트 스크립트 추가 - 수인약품 API 문서화
This commit is contained in:
parent
e470deaefc
commit
83ecf88bd4
@ -2966,7 +2966,11 @@ ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입
|
|||||||
**💊 업셀링 규칙 (중요!):**
|
**💊 업셀링 규칙 (중요!):**
|
||||||
- **항생제 질문 시**: "항생제 복용 중에는 장 건강을 위해 정장제(유산균)를 함께 급여하면 좋아요!"라고 안내하고, 제스타제 같은 정장제도 함께 추천
|
- **항생제 질문 시**: "항생제 복용 중에는 장 건강을 위해 정장제(유산균)를 함께 급여하면 좋아요!"라고 안내하고, 제스타제 같은 정장제도 함께 추천
|
||||||
- **구충제 질문 시**: 정기적인 예방 일정 안내
|
- **구충제 질문 시**: 정기적인 예방 일정 안내
|
||||||
- **피부약 질문 시**: 샴푸/케어 제품 언급 가능
|
- **피부약 질문 시**: 2단계 추천 구조 적용
|
||||||
|
- 🔹 **1차 추천(치료)**: 증상에 맞는 의약품 (개시딘겔-세균성피부염, 테르비덤-진균감염 등)
|
||||||
|
- 🔹 **2차 언급(보조케어)**: "염증이 가라앉은 후 회복기에는 스킨카솔 같은 피부케어 제품(의약외품)도 도움이 됩니다"
|
||||||
|
- ⚠️ 스킨카솔은 **의약외품**이므로 염증 "치료"용으로 추천하지 말 것! 피부 재생/보호/보습 목적으로만 언급
|
||||||
|
- 긁힘, 작은 상처, 피부 건조, 털빠짐 예방 → 스킨카솔 단독 추천 가능
|
||||||
|
|
||||||
**질문 유형별 응답:**
|
**질문 유형별 응답:**
|
||||||
|
|
||||||
@ -3062,7 +3066,7 @@ def _get_animal_drugs():
|
|||||||
"""보유 중인 동물약 목록 조회 (APC 이미지 포함)
|
"""보유 중인 동물약 목록 조회 (APC 이미지 포함)
|
||||||
|
|
||||||
APC 우선순위:
|
APC 우선순위:
|
||||||
1. CD_ITEM_UNIT_MEMBER에서 023%로 시작하는 APC 코드
|
1. CD_ITEM_UNIT_MEMBER에서 APC 코드 (0xx: ~2023년, 9xx: 2024년~)
|
||||||
2. 없으면 기존 BARCODE를 PostgreSQL에서 조회 (바코드=APC인 경우)
|
2. 없으면 기존 BARCODE를 PostgreSQL에서 조회 (바코드=APC인 경우)
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@ -3079,7 +3083,8 @@ def _get_animal_drugs():
|
|||||||
SELECT TOP 1 U.CD_CD_BARCODE
|
SELECT TOP 1 U.CD_CD_BARCODE
|
||||||
FROM CD_ITEM_UNIT_MEMBER U
|
FROM CD_ITEM_UNIT_MEMBER U
|
||||||
WHERE U.DRUGCODE = G.DrugCode
|
WHERE U.DRUGCODE = G.DrugCode
|
||||||
AND U.CD_CD_BARCODE LIKE '023%'
|
AND (U.CD_CD_BARCODE LIKE '02%' OR U.CD_CD_BARCODE LIKE '92%')
|
||||||
|
AND LEN(U.CD_CD_BARCODE) = 13
|
||||||
ORDER BY U.CHANGE_DATE DESC
|
ORDER BY U.CHANGE_DATE DESC
|
||||||
) AS APC_CODE
|
) AS APC_CODE
|
||||||
FROM CD_GOODS G
|
FROM CD_GOODS G
|
||||||
@ -3680,7 +3685,9 @@ def api_products():
|
|||||||
apc_result = drug_session.execute(text("""
|
apc_result = drug_session.execute(text("""
|
||||||
SELECT TOP 1 CD_CD_BARCODE
|
SELECT TOP 1 CD_CD_BARCODE
|
||||||
FROM CD_ITEM_UNIT_MEMBER
|
FROM CD_ITEM_UNIT_MEMBER
|
||||||
WHERE DRUGCODE = :drug_code AND CD_CD_BARCODE LIKE '023%'
|
WHERE DRUGCODE = :drug_code
|
||||||
|
AND (CD_CD_BARCODE LIKE '02%' OR CD_CD_BARCODE LIKE '92%')
|
||||||
|
AND LEN(CD_CD_BARCODE) = 13
|
||||||
"""), {'drug_code': row.drug_code})
|
"""), {'drug_code': row.drug_code})
|
||||||
apc_row = apc_result.fetchone()
|
apc_row = apc_result.fetchone()
|
||||||
if apc_row:
|
if apc_row:
|
||||||
|
|||||||
18
backend/check_chunks.py
Normal file
18
backend/check_chunks.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
rag = get_animal_rag()
|
||||||
|
rag._init_db()
|
||||||
|
|
||||||
|
df = rag.table.to_pandas()
|
||||||
|
|
||||||
|
# 개시딘 청크들 확인
|
||||||
|
gaesidin = df[df['source'] == 'gaesidin_gel_pyoderma_fusidic_acid.md']
|
||||||
|
print(f'개시딘 청크 수: {len(gaesidin)}개')
|
||||||
|
print('=' * 60)
|
||||||
|
|
||||||
|
for i, row in gaesidin.head(5).iterrows():
|
||||||
|
section = row['section']
|
||||||
|
text = row['text'][:200].replace('\n', ' ')
|
||||||
|
print(f'\n[섹션] {section}')
|
||||||
|
print(f' → {text}...')
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -596,8 +596,42 @@ def api_sooin_orders_by_kd():
|
|||||||
end_date = flask_request.args.get('end_date', today).strip()
|
end_date = flask_request.args.get('end_date', today).strip()
|
||||||
|
|
||||||
def parse_spec(spec: str) -> int:
|
def parse_spec(spec: str) -> int:
|
||||||
|
"""
|
||||||
|
규격에서 박스당 단위 수 추출
|
||||||
|
|
||||||
|
정량 단위 (T, 정, 캡슐, C, PTP, 포 등): 숫자 추출
|
||||||
|
용량 단위 (g, ml, mL, mg, L 등): 1 반환 (튜브/병 단위)
|
||||||
|
|
||||||
|
예시:
|
||||||
|
- '30T' → 30 (정제 30정)
|
||||||
|
- '100정(PTP)' → 100
|
||||||
|
- '15g' → 1 (튜브 1개)
|
||||||
|
- '10ml' → 1 (병 1개)
|
||||||
|
- '500mg' → 1 (용량 표시)
|
||||||
|
"""
|
||||||
if not spec:
|
if not spec:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
spec_lower = spec.lower()
|
||||||
|
|
||||||
|
# 용량 단위 패턴: 숫자 + g/ml/mg/l (단독 또는 끝)
|
||||||
|
# 이 경우 튜브/병 단위이므로 1 반환
|
||||||
|
volume_pattern = r'^\d+\s*(g|ml|mg|l)(\s|$|\)|/)'
|
||||||
|
if re.search(volume_pattern, spec_lower):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 정량 단위 패턴: 숫자 + T/정/캡슐/C/PTP/포
|
||||||
|
qty_pattern = r'(\d+)\s*(t|정|캡슐?|c|ptp|포|tab|cap)'
|
||||||
|
qty_match = re.search(qty_pattern, spec_lower)
|
||||||
|
if qty_match:
|
||||||
|
return int(qty_match.group(1))
|
||||||
|
|
||||||
|
# 기본: 숫자만 있으면 추출하되, 용량 단위 재확인
|
||||||
|
# 끝에 g/ml이 있으면 1 반환
|
||||||
|
if re.search(r'\d+(g|ml)$', spec_lower):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 그 외 숫자 추출
|
||||||
match = re.search(r'(\d+)', spec)
|
match = re.search(r'(\d+)', spec)
|
||||||
return int(match.group(1)) if match else 1
|
return int(match.group(1)) if match else 1
|
||||||
|
|
||||||
|
|||||||
@ -48,6 +48,87 @@
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════ 주문량 툴팁 ══════════════════ */
|
||||||
|
.order-qty-cell {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.order-qty-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 140px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.order-qty-cell:hover .order-qty-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
}
|
||||||
|
.order-qty-tooltip::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: var(--border);
|
||||||
|
}
|
||||||
|
.order-qty-tooltip-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.order-qty-tooltip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.order-qty-tooltip-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||||
|
}
|
||||||
|
.order-qty-vendor {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.order-qty-vendor-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
|
||||||
|
.order-qty-vendor-dot.sooin { background: #a855f7; }
|
||||||
|
.order-qty-vendor-dot.baekje { background: #f59e0b; }
|
||||||
|
.order-qty-vendor-dot.dongwon { background: #22c55e; }
|
||||||
|
.order-qty-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.order-qty-total {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
/* ══════════════════ 헤더 ══════════════════ */
|
/* ══════════════════ 헤더 ══════════════════ */
|
||||||
.header {
|
.header {
|
||||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
||||||
@ -958,60 +1039,45 @@
|
|||||||
|
|
||||||
let totalOrders = 0;
|
let totalOrders = 0;
|
||||||
|
|
||||||
// 지오영 데이터 합산
|
// 도매상 정보 (확장 가능)
|
||||||
if (geoRes.success && geoRes.by_kd_code) {
|
const vendorConfig = {
|
||||||
for (const [kd, data] of Object.entries(geoRes.by_kd_code)) {
|
geoyoung: { name: '지오영', icon: '🏭', res: geoRes },
|
||||||
if (!orderDataByKd[kd]) {
|
sooin: { name: '수인', icon: '💜', res: sooinRes },
|
||||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
baekje: { name: '백제', icon: '💉', res: baekjeRes },
|
||||||
}
|
dongwon: { name: '동원', icon: '🟠', res: dongwonRes }
|
||||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
};
|
||||||
orderDataByKd[kd].units += data.units || 0;
|
|
||||||
orderDataByKd[kd].sources.push('지오영');
|
|
||||||
}
|
|
||||||
totalOrders += geoRes.order_count || 0;
|
|
||||||
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 수인 데이터 합산
|
// 각 도매상 데이터 합산 (상세 정보 포함)
|
||||||
if (sooinRes.success && sooinRes.by_kd_code) {
|
for (const [vendorId, config] of Object.entries(vendorConfig)) {
|
||||||
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
|
const res = config.res;
|
||||||
|
if (res.success && res.by_kd_code) {
|
||||||
|
for (const [kd, data] of Object.entries(res.by_kd_code)) {
|
||||||
if (!orderDataByKd[kd]) {
|
if (!orderDataByKd[kd]) {
|
||||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
orderDataByKd[kd] = {
|
||||||
|
product_name: data.product_name,
|
||||||
|
spec: data.spec,
|
||||||
|
boxes: 0,
|
||||||
|
units: 0,
|
||||||
|
details: [] // 도매상별 상세 배열
|
||||||
|
};
|
||||||
}
|
}
|
||||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
const boxes = data.boxes || 0;
|
||||||
orderDataByKd[kd].units += data.units || 0;
|
const units = data.units || 0;
|
||||||
orderDataByKd[kd].sources.push('수인');
|
orderDataByKd[kd].boxes += boxes;
|
||||||
|
orderDataByKd[kd].units += units;
|
||||||
|
// 상세 정보 추가 (수량이 있는 경우만)
|
||||||
|
if (units > 0 || boxes > 0) {
|
||||||
|
orderDataByKd[kd].details.push({
|
||||||
|
vendor: vendorId,
|
||||||
|
name: config.name,
|
||||||
|
boxes: boxes,
|
||||||
|
units: units
|
||||||
|
});
|
||||||
}
|
}
|
||||||
totalOrders += sooinRes.order_count || 0;
|
|
||||||
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.order_count, '건');
|
|
||||||
}
|
}
|
||||||
|
totalOrders += res.order_count || 0;
|
||||||
// 백제 데이터 합산
|
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
|
||||||
if (baekjeRes.success && baekjeRes.by_kd_code) {
|
|
||||||
for (const [kd, data] of Object.entries(baekjeRes.by_kd_code)) {
|
|
||||||
if (!orderDataByKd[kd]) {
|
|
||||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
|
||||||
}
|
}
|
||||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
|
||||||
orderDataByKd[kd].units += data.units || 0;
|
|
||||||
orderDataByKd[kd].sources.push('백제');
|
|
||||||
}
|
|
||||||
totalOrders += baekjeRes.order_count || 0;
|
|
||||||
console.log('💉 백제 주문량:', Object.keys(baekjeRes.by_kd_code).length, '품목,', baekjeRes.order_count, '건');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 동원 데이터 합산
|
|
||||||
if (dongwonRes.success && dongwonRes.by_kd_code) {
|
|
||||||
for (const [kd, data] of Object.entries(dongwonRes.by_kd_code)) {
|
|
||||||
if (!orderDataByKd[kd]) {
|
|
||||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
|
||||||
}
|
|
||||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
|
||||||
orderDataByKd[kd].units += data.units || 0;
|
|
||||||
orderDataByKd[kd].sources.push('동원');
|
|
||||||
}
|
|
||||||
totalOrders += dongwonRes.order_count || 0;
|
|
||||||
console.log('🟠 동원 주문량:', Object.keys(dongwonRes.by_kd_code).length, '품목,', dongwonRes.order_count, '건');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
|
console.log('📦 4사 합산 주문량:', Object.keys(orderDataByKd).length, '품목,', totalOrders, '건 주문');
|
||||||
@ -1025,14 +1091,46 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// KD코드로 주문량 조회
|
// KD코드로 주문량 조회 (툴팁 포함)
|
||||||
let orderDataLoading = true; // 로딩 상태
|
let orderDataLoading = true; // 로딩 상태
|
||||||
|
|
||||||
function getOrderedQty(kdCode) {
|
function getOrderedQty(kdCode) {
|
||||||
if (orderDataLoading) return '<span class="order-loading">···</span>';
|
if (orderDataLoading) return '<span class="order-loading">···</span>';
|
||||||
const order = orderDataByKd[kdCode];
|
const order = orderDataByKd[kdCode];
|
||||||
if (!order) return '-';
|
if (!order || order.units === 0) return '-';
|
||||||
return order.units.toLocaleString();
|
|
||||||
|
// 상세 정보가 없거나 1개만 있으면 단순 표시
|
||||||
|
if (!order.details || order.details.length <= 1) {
|
||||||
|
const vendorName = order.details && order.details[0] ? order.details[0].name : '';
|
||||||
|
return `<span title="${vendorName}">${order.units.toLocaleString()}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2개 이상 도매상이면 툴팁 표시
|
||||||
|
let tooltipHtml = `<div class="order-qty-tooltip">
|
||||||
|
<div class="order-qty-tooltip-title">도매상별 주문</div>`;
|
||||||
|
|
||||||
|
for (const detail of order.details) {
|
||||||
|
tooltipHtml += `
|
||||||
|
<div class="order-qty-tooltip-row">
|
||||||
|
<span class="order-qty-vendor">
|
||||||
|
<span class="order-qty-vendor-dot ${detail.vendor}"></span>
|
||||||
|
${detail.name}
|
||||||
|
</span>
|
||||||
|
<span class="order-qty-value">${detail.units.toLocaleString()}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltipHtml += `
|
||||||
|
<div class="order-qty-tooltip-row order-qty-total">
|
||||||
|
<span>합계</span>
|
||||||
|
<span>${order.units.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return `<div class="order-qty-cell">
|
||||||
|
${order.units.toLocaleString()}
|
||||||
|
${tooltipHtml}
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────── 데이터 로드 ────────────────
|
// ──────────────── 데이터 로드 ────────────────
|
||||||
|
|||||||
@ -878,6 +878,13 @@
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.med-table tr:hover { background: #f8fafc; }
|
.med-table tr:hover { background: #f8fafc; }
|
||||||
|
.med-num {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: #7c3aed; color: #fff;
|
||||||
|
font-size: 0.7rem; font-weight: 700;
|
||||||
|
margin-right: 6px; flex-shrink: 0;
|
||||||
|
}
|
||||||
.med-name { font-weight: 600; color: #1e293b; }
|
.med-name { font-weight: 600; color: #1e293b; }
|
||||||
.med-code { font-size: 0.75rem; color: #94a3b8; }
|
.med-code { font-size: 0.75rem; color: #94a3b8; }
|
||||||
.med-dosage {
|
.med-dosage {
|
||||||
@ -1555,12 +1562,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${data.medications.map(m => `
|
${data.medications.map((m, i) => `
|
||||||
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
|
<tr data-add-info="${escapeHtml(m.add_info || '')}" ${m.is_substituted ? 'class="substituted-row"' : ''}>
|
||||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
|
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${m.is_auto_print ? 'checked' : ''}></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="med-name">
|
<div class="med-name">
|
||||||
${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
|
<span class="med-num">${i+1}</span>${m.unit_code === 2 ? '<span class="noncov-badge" title="비급여">비)</span> ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '<span class="copay-badge" title="본인부담률">' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ')</span> ' : ''}${m.ps_type === '1' ? '<span class="subst-badge" title="일반 대체조제">대)</span> ' : ''}${m.is_substituted ? '<span class="lowcost-badge" title="저가대체 인센티브">저)</span> ' : ''}${m.med_name || m.medication_code}
|
||||||
</div>
|
</div>
|
||||||
<div class="med-code">${m.medication_code}</div>
|
<div class="med-code">${m.medication_code}</div>
|
||||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||||
@ -1666,10 +1673,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${h.medications.map(m => `
|
${h.medications.map((m, i) => `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||||
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
|
${m.add_info ? `<div style="font-size:0.7rem;color:#94a3b8;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td>${m.dosage || '-'}</td>
|
<td>${m.dosage || '-'}</td>
|
||||||
@ -1790,7 +1797,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${compared.map(m => {
|
${compared.map((m, i) => {
|
||||||
const rowClass = 'row-' + m.status;
|
const rowClass = 'row-' + m.status;
|
||||||
const statusLabel = {
|
const statusLabel = {
|
||||||
added: '<span class="med-status status-added">🆕 추가</span>',
|
added: '<span class="med-status status-added">🆕 추가</span>',
|
||||||
@ -1817,7 +1824,7 @@
|
|||||||
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
|
<tr class="${rowClass}" data-add-info="${escapeHtml(m.add_info || '')}">
|
||||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
|
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}" ${disabled}></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||||
<div class="med-code">${m.medication_code}</div>
|
<div class="med-code">${m.medication_code}</div>
|
||||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
@ -1851,11 +1858,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${currentMedications.map(m => `
|
${currentMedications.map((m, i) => `
|
||||||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
|
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="med-name">${m.med_name || m.medication_code}</div>
|
<div class="med-name"><span class="med-num">${i+1}</span>${m.med_name || m.medication_code}</div>
|
||||||
<div class="med-code">${m.medication_code}</div>
|
<div class="med-code">${m.medication_code}</div>
|
||||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
49
backend/test_chatbot_api.py
Normal file
49
backend/test_chatbot_api.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""실제 챗봇 API 테스트"""
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
API_URL = "http://localhost:7001/api/animal-chat"
|
||||||
|
|
||||||
|
questions = [
|
||||||
|
"우리 강아지가 피부에 뭐가 났어요. 빨갛고 진물이 나요",
|
||||||
|
"고양이 심장사상충 예방약 뭐가 좋아요?",
|
||||||
|
"개시딘 어떻게 사용해요?",
|
||||||
|
"강아지가 구토를 해요 약 있나요?",
|
||||||
|
"진드기 예방약 추천해주세요",
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("🐾 동물의약품 챗봇 API 테스트")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
for q in questions:
|
||||||
|
print(f"\n💬 질문: {q}")
|
||||||
|
print("-" * 50)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = time.time()
|
||||||
|
resp = requests.post(API_URL, json={
|
||||||
|
"messages": [{"role": "user", "content": q}]
|
||||||
|
}, timeout=30)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("success"):
|
||||||
|
msg = data.get("message", "")
|
||||||
|
products = data.get("products", [])
|
||||||
|
|
||||||
|
# 응답 앞부분만
|
||||||
|
print(f"🤖 응답 ({elapsed:.1f}초):")
|
||||||
|
print(msg[:500] + "..." if len(msg) > 500 else msg)
|
||||||
|
|
||||||
|
if products:
|
||||||
|
print(f"\n📦 추천 제품: {', '.join([p['name'] for p in products[:3]])}")
|
||||||
|
else:
|
||||||
|
print(f"❌ 에러: {data.get('message')}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 요청 실패: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
32
backend/test_final.py
Normal file
32
backend/test_final.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""개선된 RAG 테스트"""
|
||||||
|
import importlib
|
||||||
|
import utils.animal_rag
|
||||||
|
importlib.reload(utils.animal_rag)
|
||||||
|
|
||||||
|
rag = utils.animal_rag.AnimalDrugRAG()
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
'가이시딘',
|
||||||
|
'개시딘',
|
||||||
|
'개시딘 피부염',
|
||||||
|
'심장사상충 예방약',
|
||||||
|
'강아지 구토약',
|
||||||
|
'고양이 귀진드기',
|
||||||
|
'넥스가드',
|
||||||
|
'후시딘 동물용',
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("🎯 개선된 RAG 테스트 (prefix 추가 후)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
results = rag.search(q)
|
||||||
|
print(f'\n🔍 "{q}" - {len(results)}개 결과')
|
||||||
|
for r in results[:3]: # 상위 3개만
|
||||||
|
product = r.get('product_name', '')[:20] if 'product_name' in r else ''
|
||||||
|
print(f" [{r['score']:.0%}] {r['source'][:35]}")
|
||||||
|
# 청크 prefix 확인
|
||||||
|
text_preview = r['text'][:80].replace('\n', ' ')
|
||||||
|
print(f" → {text_preview}...")
|
||||||
33
backend/test_rag_quality.py
Normal file
33
backend/test_rag_quality.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
rag = get_animal_rag()
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
'가이시딘 어떻게 써?',
|
||||||
|
'심장사상충 예방약 추천',
|
||||||
|
'고양이 구충제',
|
||||||
|
'강아지 진통제',
|
||||||
|
'귀진드기 약',
|
||||||
|
'피부염 치료',
|
||||||
|
'구토 멈추는 약',
|
||||||
|
'항생제 추천',
|
||||||
|
'넥스가드 용법',
|
||||||
|
'셀라멕틴 스팟온'
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("RAG 검색 품질 테스트")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
results = rag.search(q, n_results=3)
|
||||||
|
print(f'\n🔍 "{q}"')
|
||||||
|
if not results:
|
||||||
|
print(' ❌ 검색 결과 없음 (score < 0.3)')
|
||||||
|
else:
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")
|
||||||
|
# 첫 100자 미리보기
|
||||||
|
preview = r['text'][:100].replace('\n', ' ')
|
||||||
|
print(f" → {preview}...")
|
||||||
18
backend/test_rag_search.py
Normal file
18
backend/test_rag_search.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
rag = get_animal_rag()
|
||||||
|
|
||||||
|
# 테스트 쿼리
|
||||||
|
queries = [
|
||||||
|
"피부 붉고 염증",
|
||||||
|
"피부염 치료",
|
||||||
|
"피부 발적 연고",
|
||||||
|
]
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
print(f"\n=== 검색: {query} ===")
|
||||||
|
results = rag.search(query, n_results=5)
|
||||||
|
if not results:
|
||||||
|
print(" (결과 없음)")
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['score']:.0%}] {r['source']} - {r['section']}")
|
||||||
26
backend/test_skincasol.py
Normal file
26
backend/test_skincasol.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
rag = get_animal_rag()
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
"스킨카솔",
|
||||||
|
"센텔라",
|
||||||
|
"피부 재생",
|
||||||
|
"피부 보호",
|
||||||
|
"피부 진정",
|
||||||
|
"상처 회복",
|
||||||
|
"피부 케어",
|
||||||
|
"습진",
|
||||||
|
"아토피",
|
||||||
|
"티트리오일",
|
||||||
|
]
|
||||||
|
|
||||||
|
for query in queries:
|
||||||
|
results = rag.search(query, n_results=3)
|
||||||
|
has_skincasol = any("skincasol" in r["source"].lower() for r in results)
|
||||||
|
mark = "O" if has_skincasol else "X"
|
||||||
|
print(f"[{mark}] {query}")
|
||||||
|
if has_skincasol:
|
||||||
|
for r in results:
|
||||||
|
if "skincasol" in r["source"].lower():
|
||||||
|
print(f" -> {r['score']:.0%} {r['section']}")
|
||||||
31
backend/test_threshold.py
Normal file
31
backend/test_threshold.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""임계값 없이 raw 검색 결과 확인"""
|
||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
rag = get_animal_rag()
|
||||||
|
rag._init_db()
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
'가이시딘', # 오타 버전
|
||||||
|
'개시딘', # 정확한 이름
|
||||||
|
'개시딘 겔',
|
||||||
|
'피부 농피증',
|
||||||
|
'후시딘', # 사람용 약 이름으로 검색
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 70)
|
||||||
|
print("임계값 제거 후 RAW 검색 결과 (상위 5개)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
# 임계값 없이 raw 검색
|
||||||
|
query_emb = rag._get_embedding(q)
|
||||||
|
results = rag.table.search(query_emb).limit(5).to_list()
|
||||||
|
|
||||||
|
print(f'\n🔍 "{q}"')
|
||||||
|
for r in results:
|
||||||
|
distance = r.get("_distance", 10)
|
||||||
|
score = 1 / (1 + distance)
|
||||||
|
source = r["source"]
|
||||||
|
section = r["section"]
|
||||||
|
print(f" [{score:.1%}] (dist:{distance:.2f}) {source} - {section}")
|
||||||
28
backend/test_tuned.py
Normal file
28
backend/test_tuned.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""튜닝 후 테스트 (기본값 사용)"""
|
||||||
|
from utils.animal_rag import get_animal_rag
|
||||||
|
|
||||||
|
# 새로 import해서 변경사항 적용
|
||||||
|
import importlib
|
||||||
|
import utils.animal_rag
|
||||||
|
importlib.reload(utils.animal_rag)
|
||||||
|
|
||||||
|
rag = utils.animal_rag.get_animal_rag()
|
||||||
|
|
||||||
|
queries = [
|
||||||
|
'가이시딘',
|
||||||
|
'개시딘 피부염',
|
||||||
|
'심장사상충 예방약',
|
||||||
|
'강아지 구토약',
|
||||||
|
]
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("튜닝 후 테스트 (n_results=5, threshold=0.2)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
# 기본값 사용 (n_results=5)
|
||||||
|
results = rag.search(q)
|
||||||
|
print(f'\n🔍 "{q}" - {len(results)}개 결과')
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['score']:.0%}] {r['source'][:30]} - {r['section'][:20]}")
|
||||||
21
backend/test_vomit.py
Normal file
21
backend/test_vomit.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import importlib
|
||||||
|
import utils.animal_rag as ar
|
||||||
|
importlib.reload(ar)
|
||||||
|
|
||||||
|
rag = ar.AnimalDrugRAG()
|
||||||
|
rag._init_db()
|
||||||
|
|
||||||
|
queries = ['구토 멈추는 약', '구토 치료제', '마로피턴트', '세레니아']
|
||||||
|
|
||||||
|
for q in queries:
|
||||||
|
query_emb = rag._get_embedding(q)
|
||||||
|
results = rag.table.search(query_emb).limit(5).to_list()
|
||||||
|
|
||||||
|
print(f'\n🔍 "{q}"')
|
||||||
|
for r in results:
|
||||||
|
dist = r.get('_distance', 10)
|
||||||
|
score = 1 / (1 + dist)
|
||||||
|
source = r['source'][:40]
|
||||||
|
section = r['section'][:25]
|
||||||
|
print(f' [{score:.0%}] {source} - {section}')
|
||||||
@ -106,12 +106,56 @@ class AnimalDrugRAG:
|
|||||||
|
|
||||||
return embeddings
|
return embeddings
|
||||||
|
|
||||||
|
def _extract_product_info(self, content: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
MD 파일 상단에서 제품 정보 추출
|
||||||
|
- 제품명 (한글/영문)
|
||||||
|
- 성분
|
||||||
|
- 대상 동물
|
||||||
|
"""
|
||||||
|
info = {"product_name": "", "ingredients": "", "target_animal": ""}
|
||||||
|
|
||||||
|
# # 제목에서 제품명 추출 (예: "# 복합 개시딘 겔 - 표면성...")
|
||||||
|
title_match = re.search(r'^# (.+?)(?:\s*[-–—]|$)', content, re.MULTILINE)
|
||||||
|
if title_match:
|
||||||
|
info["product_name"] = title_match.group(1).strip()
|
||||||
|
|
||||||
|
# > 성분: 라인에서 추출
|
||||||
|
ingredient_match = re.search(r'>\s*성분[:\s]+(.+?)(?:\n|$)', content)
|
||||||
|
if ingredient_match:
|
||||||
|
info["ingredients"] = ingredient_match.group(1).strip()[:100] # 100자 제한
|
||||||
|
|
||||||
|
# 대상 동물 추출 (테이블에서)
|
||||||
|
animal_match = re.search(r'\*\*대상\s*동물\*\*[^\|]*\|\s*([^\|]+)', content)
|
||||||
|
if animal_match:
|
||||||
|
info["target_animal"] = animal_match.group(1).strip()
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
def _make_chunk_prefix(self, product_info: Dict[str, str]) -> str:
|
||||||
|
"""청크 prefix 생성"""
|
||||||
|
parts = []
|
||||||
|
if product_info["product_name"]:
|
||||||
|
parts.append(f"제품명: {product_info['product_name']}")
|
||||||
|
if product_info["target_animal"]:
|
||||||
|
parts.append(f"대상: {product_info['target_animal']}")
|
||||||
|
if product_info["ingredients"]:
|
||||||
|
parts.append(f"성분: {product_info['ingredients']}")
|
||||||
|
|
||||||
|
if parts:
|
||||||
|
return "[" + " | ".join(parts) + "]\n\n"
|
||||||
|
return ""
|
||||||
|
|
||||||
def chunk_markdown(self, content: str, source_file: str) -> List[Dict]:
|
def chunk_markdown(self, content: str, source_file: str) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
마크다운 청킹 (섹션 기반)
|
마크다운 청킹 (섹션 기반 + 제품명 prefix)
|
||||||
"""
|
"""
|
||||||
chunks = []
|
chunks = []
|
||||||
|
|
||||||
|
# 제품 정보 추출 & prefix 생성
|
||||||
|
product_info = self._extract_product_info(content)
|
||||||
|
prefix = self._make_chunk_prefix(product_info)
|
||||||
|
|
||||||
# ## 헤더 기준 분리
|
# ## 헤더 기준 분리
|
||||||
sections = re.split(r'\n(?=## )', content)
|
sections = re.split(r'\n(?=## )', content)
|
||||||
|
|
||||||
@ -123,26 +167,34 @@ class AnimalDrugRAG:
|
|||||||
title_match = re.match(r'^## (.+?)(?:\n|$)', section)
|
title_match = re.match(r'^## (.+?)(?:\n|$)', section)
|
||||||
section_title = title_match.group(1).strip() if title_match else f"섹션{i+1}"
|
section_title = title_match.group(1).strip() if title_match else f"섹션{i+1}"
|
||||||
|
|
||||||
|
# prefix + section 결합
|
||||||
|
prefixed_section = prefix + section
|
||||||
|
|
||||||
# 큰 섹션은 추가 분할
|
# 큰 섹션은 추가 분할
|
||||||
if len(section) > CHUNK_SIZE:
|
if len(prefixed_section) > CHUNK_SIZE:
|
||||||
sub_chunks = self._split_by_size(section, CHUNK_SIZE, CHUNK_OVERLAP)
|
sub_chunks = self._split_by_size(prefixed_section, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||||
for j, sub_chunk in enumerate(sub_chunks):
|
for j, sub_chunk in enumerate(sub_chunks):
|
||||||
|
# 분할된 청크에도 prefix 보장 (overlap으로 잘렸을 경우)
|
||||||
|
if j > 0 and not sub_chunk.startswith("["):
|
||||||
|
sub_chunk = prefix + sub_chunk
|
||||||
chunk_id = f"{source_file}#{section_title}#{j}"
|
chunk_id = f"{source_file}#{section_title}#{j}"
|
||||||
chunks.append({
|
chunks.append({
|
||||||
"id": chunk_id,
|
"id": chunk_id,
|
||||||
"text": sub_chunk,
|
"text": sub_chunk,
|
||||||
"source": source_file,
|
"source": source_file,
|
||||||
"section": section_title,
|
"section": section_title,
|
||||||
"chunk_index": j
|
"chunk_index": j,
|
||||||
|
"product_name": product_info["product_name"]
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
chunk_id = f"{source_file}#{section_title}"
|
chunk_id = f"{source_file}#{section_title}"
|
||||||
chunks.append({
|
chunks.append({
|
||||||
"id": chunk_id,
|
"id": chunk_id,
|
||||||
"text": section,
|
"text": prefixed_section,
|
||||||
"source": source_file,
|
"source": source_file,
|
||||||
"section": section_title,
|
"section": section_title,
|
||||||
"chunk_index": 0
|
"chunk_index": 0,
|
||||||
|
"product_name": product_info["product_name"]
|
||||||
})
|
})
|
||||||
|
|
||||||
return chunks
|
return chunks
|
||||||
@ -215,6 +267,7 @@ class AnimalDrugRAG:
|
|||||||
"source": chunk["source"],
|
"source": chunk["source"],
|
||||||
"section": chunk["section"],
|
"section": chunk["section"],
|
||||||
"chunk_index": chunk["chunk_index"],
|
"chunk_index": chunk["chunk_index"],
|
||||||
|
"product_name": chunk.get("product_name", ""),
|
||||||
"vector": emb
|
"vector": emb
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -224,7 +277,7 @@ class AnimalDrugRAG:
|
|||||||
|
|
||||||
return len(data)
|
return len(data)
|
||||||
|
|
||||||
def search(self, query: str, n_results: int = 3) -> List[Dict]:
|
def search(self, query: str, n_results: int = 5) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
유사도 검색
|
유사도 검색
|
||||||
"""
|
"""
|
||||||
@ -248,9 +301,9 @@ class AnimalDrugRAG:
|
|||||||
distance = r.get("_distance", 10)
|
distance = r.get("_distance", 10)
|
||||||
score = 1 / (1 + distance) # 0~1 범위로 변환
|
score = 1 / (1 + distance) # 0~1 범위로 변환
|
||||||
|
|
||||||
# 임계값: 유사도 0.3 미만은 제외 (관련 없는 문서)
|
# 임계값: 유사도 0.2 미만은 제외 (관련 없는 문서)
|
||||||
# L2 거리 2.33 이상이면 제외
|
# L2 거리 4.0 이상이면 제외
|
||||||
if score < 0.3:
|
if score < 0.2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
output.append({
|
output.append({
|
||||||
|
|||||||
63
docs/RX_USAGE_ORDER_DETAIL_IMPL.md
Normal file
63
docs/RX_USAGE_ORDER_DETAIL_IMPL.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Rx-Usage 주문량 상세 (도매상별) 툴팁 기능
|
||||||
|
|
||||||
|
## 구현일: 2026-06-19
|
||||||
|
|
||||||
|
## 배경
|
||||||
|
- `/admin/rx-usage` 페이지에서 주문량이 합계로만 표시됨
|
||||||
|
- 사용자가 어떤 도매상에 얼마나 주문했는지 확인 필요
|
||||||
|
|
||||||
|
## 구현 내용
|
||||||
|
|
||||||
|
### 1. 데이터 구조 변경 (`loadOrderData` 함수)
|
||||||
|
|
||||||
|
**기존:**
|
||||||
|
```javascript
|
||||||
|
orderDataByKd[kd] = {
|
||||||
|
product_name, spec, boxes, units,
|
||||||
|
sources: ['지오영', '수인'] // 이름만 저장
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경:**
|
||||||
|
```javascript
|
||||||
|
orderDataByKd[kd] = {
|
||||||
|
product_name, spec, boxes, units,
|
||||||
|
details: [
|
||||||
|
{ vendor: 'geoyoung', name: '지오영', boxes: 10, units: 100 },
|
||||||
|
{ vendor: 'sooin', name: '수인', boxes: 5, units: 50 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 툴팁 CSS 추가
|
||||||
|
|
||||||
|
```css
|
||||||
|
.order-qty-cell { position: relative; cursor: pointer; }
|
||||||
|
.order-qty-tooltip { /* 툴팁 스타일 */ }
|
||||||
|
.order-qty-vendor-dot.geoyoung { background: #06b6d4; }
|
||||||
|
.order-qty-vendor-dot.sooin { background: #a855f7; }
|
||||||
|
.order-qty-vendor-dot.baekje { background: #f59e0b; }
|
||||||
|
.order-qty-vendor-dot.dongwon { background: #22c55e; }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `getOrderedQty()` 함수 수정
|
||||||
|
|
||||||
|
- 단일 도매상: 단순 숫자 표시
|
||||||
|
- 복수 도매상: hover 시 도매상별 상세 툴팁 표시
|
||||||
|
|
||||||
|
## 수정 파일
|
||||||
|
- `backend/templates/admin_rx_usage.html`
|
||||||
|
|
||||||
|
## 동작
|
||||||
|
1. 주문량 셀에 마우스 hover
|
||||||
|
2. 2개 이상 도매상에서 주문한 경우 툴팁 표시
|
||||||
|
3. 각 도매상별 수량과 합계 표시
|
||||||
|
|
||||||
|
## 확장 포인트
|
||||||
|
- `vendorConfig` 객체에 새 도매상 추가 시 자동 지원
|
||||||
|
- 도매상별 색상은 CSS의 `.order-qty-vendor-dot` 클래스로 관리
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
- URL: http://localhost:7001/admin/rx-usage
|
||||||
|
- 기간 조회 후 "주문량" 컬럼 확인
|
||||||
|
- 여러 도매상 주문이 있는 품목에서 hover 시 툴팁 확인
|
||||||
130
docs/SUIN_API_FIX.md
Normal file
130
docs/SUIN_API_FIX.md
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
# 수인 API 주문 수량 파싱 문제 수정
|
||||||
|
|
||||||
|
**날짜**: 2026-03-09
|
||||||
|
**문제**: 라미실크림 15g 주문 시 **1개 → 15개**로 잘못 표시
|
||||||
|
**원인**: `parse_spec` 함수에서 용량 단위(g, ml)를 정량 단위(T, 정)로 착각
|
||||||
|
|
||||||
|
## 📋 문제 상황
|
||||||
|
|
||||||
|
| 도매상 | 제품 | 실제 주문 | 표시된 수량 |
|
||||||
|
|--------|------|----------|------------|
|
||||||
|
| 동원 | 라미실크림 15g | 1개 | **1개** ✅ |
|
||||||
|
| 수인 | 라미실크림 15g | 1개 | **15개** ❌ |
|
||||||
|
|
||||||
|
## 🔍 원인 분석
|
||||||
|
|
||||||
|
### 문제 코드 위치
|
||||||
|
- **파일**: `sooin_api.py` (Flask Blueprint)
|
||||||
|
- **API**: `GET /api/sooin/orders/summary-by-kd`
|
||||||
|
- **함수**: `parse_spec()`
|
||||||
|
|
||||||
|
### 기존 코드 (문제)
|
||||||
|
```python
|
||||||
|
def parse_spec(spec: str) -> int:
|
||||||
|
if not spec:
|
||||||
|
return 1
|
||||||
|
match = re.search(r'(\d+)', spec)
|
||||||
|
return int(match.group(1)) if match else 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**문제점**: 규격에서 **숫자만 추출**
|
||||||
|
- `'30T'` → 30 (정제 30정) ✅
|
||||||
|
- `'15g'` → 15 🚨 **문제!** (튜브 15그램인데 15개로 계산)
|
||||||
|
|
||||||
|
### 계산 과정
|
||||||
|
```
|
||||||
|
수인 라미실크림 15g 1박스 주문
|
||||||
|
→ quantity = 1
|
||||||
|
→ per_unit = parse_spec('15g') = 15
|
||||||
|
→ total_units = 1 × 15 = 15개 ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### 동원 API는 정상인 이유
|
||||||
|
동원의 `parse_spec()` (wholesale/dongwon.py:1718-1720):
|
||||||
|
```python
|
||||||
|
# mg/ml 등의 용량 단위는 1로 처리
|
||||||
|
if re.search(r'\d+\s*(mg|ml|g)\b', spec, re.IGNORECASE):
|
||||||
|
return 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ 수정 내용
|
||||||
|
|
||||||
|
### 수정된 코드
|
||||||
|
```python
|
||||||
|
def parse_spec(spec: str) -> int:
|
||||||
|
"""
|
||||||
|
규격에서 박스당 단위 수 추출
|
||||||
|
|
||||||
|
정량 단위 (T, 정, 캡슐, C, PTP, 포 등): 숫자 추출
|
||||||
|
용량 단위 (g, ml, mL, mg, L 등): 1 반환 (튜브/병 단위)
|
||||||
|
|
||||||
|
예시:
|
||||||
|
- '30T' → 30 (정제 30정)
|
||||||
|
- '100정(PTP)' → 100
|
||||||
|
- '15g' → 1 (튜브 1개)
|
||||||
|
- '10ml' → 1 (병 1개)
|
||||||
|
- '500mg' → 1 (용량 표시)
|
||||||
|
"""
|
||||||
|
if not spec:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
spec_lower = spec.lower()
|
||||||
|
|
||||||
|
# 용량 단위 패턴: 숫자 + g/ml/mg/l (단독 또는 끝)
|
||||||
|
# 이 경우 튜브/병 단위이므로 1 반환
|
||||||
|
volume_pattern = r'^\d+\s*(g|ml|mg|l)(\s|$|\)|/)'
|
||||||
|
if re.search(volume_pattern, spec_lower):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 정량 단위 패턴: 숫자 + T/정/캡슐/C/PTP/포
|
||||||
|
qty_pattern = r'(\d+)\s*(t|정|캡슐?|c|ptp|포|tab|cap)'
|
||||||
|
qty_match = re.search(qty_pattern, spec_lower)
|
||||||
|
if qty_match:
|
||||||
|
return int(qty_match.group(1))
|
||||||
|
|
||||||
|
# 기본: 숫자만 있으면 추출하되, 용량 단위 재확인
|
||||||
|
# 끝에 g/ml이 있으면 1 반환
|
||||||
|
if re.search(r'\d+(g|ml)$', spec_lower):
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# 그 외 숫자 추출
|
||||||
|
match = re.search(r'(\d+)', spec)
|
||||||
|
return int(match.group(1)) if match else 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정 결과
|
||||||
|
| 규격 | 기존 결과 | 수정 후 결과 |
|
||||||
|
|------|----------|-------------|
|
||||||
|
| `'30T'` | 30 | 30 ✅ |
|
||||||
|
| `'100정(PTP)'` | 100 | 100 ✅ |
|
||||||
|
| `'15g'` | 15 ❌ | **1** ✅ |
|
||||||
|
| `'10ml'` | 10 ❌ | **1** ✅ |
|
||||||
|
| `'500mg'` | 500 ❌ | **1** ✅ |
|
||||||
|
|
||||||
|
## 📁 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/sooin_api.py` | Flask Blueprint (수정됨) |
|
||||||
|
| `wholesale/sooin.py` | 수인약품 핵심 API 클래스 |
|
||||||
|
| `wholesale/dongwon.py` | 동원약품 API (참고) |
|
||||||
|
|
||||||
|
## 🔄 적용 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Flask 서버 재시작
|
||||||
|
pm2 restart flask-pharmacy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 테스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 수인 주문 조회 API 테스트
|
||||||
|
curl "http://localhost:7001/api/sooin/orders/summary-by-kd?start_date=2026-03-01&end_date=2026-03-09"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 참고
|
||||||
|
|
||||||
|
- **도매상 API 문서**: `docs/WHOLESALE_API_INTEGRATION.md`
|
||||||
|
- **수인 API 문서**: `docs/SOOIN_API.md`
|
||||||
|
- **동원 API**: 이미 올바른 `parse_spec` 로직 적용됨
|
||||||
Loading…
Reference in New Issue
Block a user