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 우선순위:
|
||||
1. CD_ITEM_UNIT_MEMBER에서 023%로 시작하는 APC 코드
|
||||
1. CD_ITEM_UNIT_MEMBER에서 APC 코드 (0xx: ~2023년, 9xx: 2024년~)
|
||||
2. 없으면 기존 BARCODE를 PostgreSQL에서 조회 (바코드=APC인 경우)
|
||||
"""
|
||||
try:
|
||||
@ -3079,7 +3083,8 @@ def _get_animal_drugs():
|
||||
SELECT TOP 1 U.CD_CD_BARCODE
|
||||
FROM CD_ITEM_UNIT_MEMBER U
|
||||
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
|
||||
) AS APC_CODE
|
||||
FROM CD_GOODS G
|
||||
@ -3680,7 +3685,9 @@ def api_products():
|
||||
apc_result = drug_session.execute(text("""
|
||||
SELECT TOP 1 CD_CD_BARCODE
|
||||
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})
|
||||
apc_row = apc_result.fetchone()
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -48,6 +48,87 @@
|
||||
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 {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 50%, #22d3ee 100%);
|
||||
@ -958,60 +1039,45 @@
|
||||
|
||||
let totalOrders = 0;
|
||||
|
||||
// 지오영 데이터 합산
|
||||
if (geoRes.success && geoRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(geoRes.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 += geoRes.order_count || 0;
|
||||
console.log('🏭 지오영 주문량:', Object.keys(geoRes.by_kd_code).length, '품목,', geoRes.order_count, '건');
|
||||
}
|
||||
// 도매상 정보 (확장 가능)
|
||||
const vendorConfig = {
|
||||
geoyoung: { name: '지오영', icon: '🏭', res: geoRes },
|
||||
sooin: { name: '수인', icon: '💜', res: sooinRes },
|
||||
baekje: { name: '백제', icon: '💉', res: baekjeRes },
|
||||
dongwon: { name: '동원', icon: '🟠', res: dongwonRes }
|
||||
};
|
||||
|
||||
// 수인 데이터 합산
|
||||
if (sooinRes.success && sooinRes.by_kd_code) {
|
||||
for (const [kd, data] of Object.entries(sooinRes.by_kd_code)) {
|
||||
if (!orderDataByKd[kd]) {
|
||||
orderDataByKd[kd] = { product_name: data.product_name, spec: data.spec, boxes: 0, units: 0, sources: [] };
|
||||
// 각 도매상 데이터 합산 (상세 정보 포함)
|
||||
for (const [vendorId, config] of Object.entries(vendorConfig)) {
|
||||
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]) {
|
||||
orderDataByKd[kd] = {
|
||||
product_name: data.product_name,
|
||||
spec: data.spec,
|
||||
boxes: 0,
|
||||
units: 0,
|
||||
details: [] // 도매상별 상세 배열
|
||||
};
|
||||
}
|
||||
const boxes = data.boxes || 0;
|
||||
const units = data.units || 0;
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
orderDataByKd[kd].boxes += data.boxes || 0;
|
||||
orderDataByKd[kd].units += data.units || 0;
|
||||
orderDataByKd[kd].sources.push('수인');
|
||||
totalOrders += res.order_count || 0;
|
||||
console.log(`${config.icon} ${config.name} 주문량:`, Object.keys(res.by_kd_code).length, '품목,', res.order_count, '건');
|
||||
}
|
||||
totalOrders += sooinRes.order_count || 0;
|
||||
console.log('💜 수인 주문량:', Object.keys(sooinRes.by_kd_code).length, '품목,', sooinRes.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, '건 주문');
|
||||
@ -1025,14 +1091,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// KD코드로 주문량 조회
|
||||
// KD코드로 주문량 조회 (툴팁 포함)
|
||||
let orderDataLoading = true; // 로딩 상태
|
||||
|
||||
function getOrderedQty(kdCode) {
|
||||
if (orderDataLoading) return '<span class="order-loading">···</span>';
|
||||
const order = orderDataByKd[kdCode];
|
||||
if (!order) return '-';
|
||||
return order.units.toLocaleString();
|
||||
if (!order || order.units === 0) return '-';
|
||||
|
||||
// 상세 정보가 없거나 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;
|
||||
}
|
||||
.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-code { font-size: 0.75rem; color: #94a3b8; }
|
||||
.med-dosage {
|
||||
@ -1555,12 +1562,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${data.medications.map(m => `
|
||||
${data.medications.map((m, i) => `
|
||||
<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>
|
||||
<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 class="med-code">${m.medication_code}</div>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
@ -1666,10 +1673,10 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${h.medications.map(m => `
|
||||
${h.medications.map((m, i) => `
|
||||
<tr>
|
||||
<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>` : ''}
|
||||
</td>
|
||||
<td>${m.dosage || '-'}</td>
|
||||
@ -1790,7 +1797,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${compared.map(m => {
|
||||
${compared.map((m, i) => {
|
||||
const rowClass = 'row-' + m.status;
|
||||
const statusLabel = {
|
||||
added: '<span class="med-status status-added">🆕 추가</span>',
|
||||
@ -1817,7 +1824,7 @@
|
||||
<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>
|
||||
<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>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
</td>
|
||||
@ -1851,11 +1858,11 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${currentMedications.map(m => `
|
||||
${currentMedications.map((m, i) => `
|
||||
<tr data-add-info="${escapeHtml(m.add_info || '')}">
|
||||
<td><input type="checkbox" class="med-check" data-code="${m.medication_code}"></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>
|
||||
${m.add_info ? `<div style="font-size:0.75rem;color:#6b7280;">${escapeHtml(m.add_info)}</div>` : ''}
|
||||
</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
|
||||
|
||||
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]:
|
||||
"""
|
||||
마크다운 청킹 (섹션 기반)
|
||||
마크다운 청킹 (섹션 기반 + 제품명 prefix)
|
||||
"""
|
||||
chunks = []
|
||||
|
||||
# 제품 정보 추출 & prefix 생성
|
||||
product_info = self._extract_product_info(content)
|
||||
prefix = self._make_chunk_prefix(product_info)
|
||||
|
||||
# ## 헤더 기준 분리
|
||||
sections = re.split(r'\n(?=## )', content)
|
||||
|
||||
@ -123,26 +167,34 @@ class AnimalDrugRAG:
|
||||
title_match = re.match(r'^## (.+?)(?:\n|$)', section)
|
||||
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:
|
||||
sub_chunks = self._split_by_size(section, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
if len(prefixed_section) > CHUNK_SIZE:
|
||||
sub_chunks = self._split_by_size(prefixed_section, CHUNK_SIZE, CHUNK_OVERLAP)
|
||||
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}"
|
||||
chunks.append({
|
||||
"id": chunk_id,
|
||||
"text": sub_chunk,
|
||||
"source": source_file,
|
||||
"section": section_title,
|
||||
"chunk_index": j
|
||||
"chunk_index": j,
|
||||
"product_name": product_info["product_name"]
|
||||
})
|
||||
else:
|
||||
chunk_id = f"{source_file}#{section_title}"
|
||||
chunks.append({
|
||||
"id": chunk_id,
|
||||
"text": section,
|
||||
"text": prefixed_section,
|
||||
"source": source_file,
|
||||
"section": section_title,
|
||||
"chunk_index": 0
|
||||
"chunk_index": 0,
|
||||
"product_name": product_info["product_name"]
|
||||
})
|
||||
|
||||
return chunks
|
||||
@ -215,6 +267,7 @@ class AnimalDrugRAG:
|
||||
"source": chunk["source"],
|
||||
"section": chunk["section"],
|
||||
"chunk_index": chunk["chunk_index"],
|
||||
"product_name": chunk.get("product_name", ""),
|
||||
"vector": emb
|
||||
})
|
||||
|
||||
@ -224,7 +277,7 @@ class AnimalDrugRAG:
|
||||
|
||||
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)
|
||||
score = 1 / (1 + distance) # 0~1 범위로 변환
|
||||
|
||||
# 임계값: 유사도 0.3 미만은 제외 (관련 없는 문서)
|
||||
# L2 거리 2.33 이상이면 제외
|
||||
if score < 0.3:
|
||||
# 임계값: 유사도 0.2 미만은 제외 (관련 없는 문서)
|
||||
# L2 거리 4.0 이상이면 제외
|
||||
if score < 0.2:
|
||||
continue
|
||||
|
||||
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