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:
thug0bin 2026-03-11 14:17:04 +09:00
parent e470deaefc
commit 83ecf88bd4
21 changed files with 724 additions and 76 deletions

View File

@ -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
View 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}...')

View File

@ -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

View File

@ -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>`;
}
// ──────────────── 데이터 로드 ────────────────

View File

@ -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>

View 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
View 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}...")

View 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}...")

View 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
View 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
View 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
View 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
View 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}')

View File

@ -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({

View 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
View 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` 로직 적용됨