diff --git a/backend/app.py b/backend/app.py
index c853efb..c105861 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -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:
diff --git a/backend/check_chunks.py b/backend/check_chunks.py
new file mode 100644
index 0000000..784d557
--- /dev/null
+++ b/backend/check_chunks.py
@@ -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}...')
diff --git a/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-933ff3c4-220e-4547-ab20-88bb9eb16945.txn b/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-933ff3c4-220e-4547-ab20-88bb9eb16945.txn
deleted file mode 100644
index 0269d5f..0000000
Binary files a/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-933ff3c4-220e-4547-ab20-88bb9eb16945.txn and /dev/null differ
diff --git a/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-e2807051-d75c-44db-a549-c7d6f3b67855.txn b/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-e2807051-d75c-44db-a549-c7d6f3b67855.txn
new file mode 100644
index 0000000..3137914
Binary files /dev/null and b/backend/db/lance_animal_drugs/animal_drugs.lance/_transactions/0-e2807051-d75c-44db-a549-c7d6f3b67855.txn differ
diff --git a/backend/db/lance_animal_drugs/animal_drugs.lance/_versions/18446744073709551614.manifest b/backend/db/lance_animal_drugs/animal_drugs.lance/_versions/18446744073709551614.manifest
index 363c861..47f10a0 100644
Binary files a/backend/db/lance_animal_drugs/animal_drugs.lance/_versions/18446744073709551614.manifest and b/backend/db/lance_animal_drugs/animal_drugs.lance/_versions/18446744073709551614.manifest differ
diff --git a/backend/db/lance_animal_drugs/animal_drugs.lance/data/000110001011000110111011d274b147909cc57df7b4f64e9d.lance b/backend/db/lance_animal_drugs/animal_drugs.lance/data/000110001011000110111011d274b147909cc57df7b4f64e9d.lance
new file mode 100644
index 0000000..ca7afb3
Binary files /dev/null and b/backend/db/lance_animal_drugs/animal_drugs.lance/data/000110001011000110111011d274b147909cc57df7b4f64e9d.lance differ
diff --git a/backend/db/lance_animal_drugs/animal_drugs.lance/data/011101011010000111010000397df14425939449f00bbdc140.lance b/backend/db/lance_animal_drugs/animal_drugs.lance/data/011101011010000111010000397df14425939449f00bbdc140.lance
deleted file mode 100644
index c1991c9..0000000
Binary files a/backend/db/lance_animal_drugs/animal_drugs.lance/data/011101011010000111010000397df14425939449f00bbdc140.lance and /dev/null differ
diff --git a/backend/sooin_api.py b/backend/sooin_api.py
index 93d9cd7..d355c77 100644
--- a/backend/sooin_api.py
+++ b/backend/sooin_api.py
@@ -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
diff --git a/backend/templates/admin_rx_usage.html b/backend/templates/admin_rx_usage.html
index 9edf356..c67e310 100644
--- a/backend/templates/admin_rx_usage.html
+++ b/backend/templates/admin_rx_usage.html
@@ -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 '···';
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 `${order.units.toLocaleString()}`;
+ }
+
+ // 2개 이상 도매상이면 툴팁 표시
+ let tooltipHtml = `
`;
+
+ return `
+ ${order.units.toLocaleString()}
+ ${tooltipHtml}
+
`;
}
// ──────────────── 데이터 로드 ────────────────
diff --git a/backend/templates/pmr.html b/backend/templates/pmr.html
index 3413106..7ab118b 100644
--- a/backend/templates/pmr.html
+++ b/backend/templates/pmr.html
@@ -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 @@
- ${data.medications.map(m => `
+ ${data.medications.map((m, i) => `
|
- ${m.unit_code === 2 ? '비) ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ') ' : ''}${m.ps_type === '1' ? '대) ' : ''}${m.is_substituted ? '저) ' : ''}${m.med_name || m.medication_code}
+ ${i+1}${m.unit_code === 2 ? '비) ' : ''}${[3,4,5,6,7].includes(m.unit_code) ? '' + {3:'100',4:'50',5:'80',6:'30',7:'90'}[m.unit_code] + ') ' : ''}${m.ps_type === '1' ? '대) ' : ''}${m.is_substituted ? '저) ' : ''}${m.med_name || m.medication_code}
${m.medication_code}
${m.add_info ? `${escapeHtml(m.add_info)} ` : ''}
@@ -1666,10 +1673,10 @@
|
- ${h.medications.map(m => `
+ ${h.medications.map((m, i) => `
|
- ${m.med_name || m.medication_code}
+ ${i+1}${m.med_name || m.medication_code}
${m.add_info ? `${escapeHtml(m.add_info)} ` : ''}
|
${m.dosage || '-'} |
@@ -1790,7 +1797,7 @@
- ${compared.map(m => {
+ ${compared.map((m, i) => {
const rowClass = 'row-' + m.status;
const statusLabel = {
added: '🆕 추가',
@@ -1817,7 +1824,7 @@
|
- ${m.med_name || m.medication_code}
+ ${i+1}${m.med_name || m.medication_code}
${m.medication_code}
${m.add_info ? `${escapeHtml(m.add_info)} ` : ''}
|
@@ -1851,11 +1858,11 @@
- ${currentMedications.map(m => `
+ ${currentMedications.map((m, i) => `
|
- ${m.med_name || m.medication_code}
+ ${i+1}${m.med_name || m.medication_code}
${m.medication_code}
${m.add_info ? `${escapeHtml(m.add_info)} ` : ''}
|
diff --git a/backend/test_chatbot_api.py b/backend/test_chatbot_api.py
new file mode 100644
index 0000000..6422983
--- /dev/null
+++ b/backend/test_chatbot_api.py
@@ -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()
diff --git a/backend/test_final.py b/backend/test_final.py
new file mode 100644
index 0000000..1c0f35c
--- /dev/null
+++ b/backend/test_final.py
@@ -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}...")
diff --git a/backend/test_rag_quality.py b/backend/test_rag_quality.py
new file mode 100644
index 0000000..3ef86a6
--- /dev/null
+++ b/backend/test_rag_quality.py
@@ -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}...")
diff --git a/backend/test_rag_search.py b/backend/test_rag_search.py
new file mode 100644
index 0000000..439810a
--- /dev/null
+++ b/backend/test_rag_search.py
@@ -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']}")
diff --git a/backend/test_skincasol.py b/backend/test_skincasol.py
new file mode 100644
index 0000000..0b09c5b
--- /dev/null
+++ b/backend/test_skincasol.py
@@ -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']}")
diff --git a/backend/test_threshold.py b/backend/test_threshold.py
new file mode 100644
index 0000000..4cb3037
--- /dev/null
+++ b/backend/test_threshold.py
@@ -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}")
diff --git a/backend/test_tuned.py b/backend/test_tuned.py
new file mode 100644
index 0000000..c931244
--- /dev/null
+++ b/backend/test_tuned.py
@@ -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]}")
diff --git a/backend/test_vomit.py b/backend/test_vomit.py
new file mode 100644
index 0000000..7e02f40
--- /dev/null
+++ b/backend/test_vomit.py
@@ -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}')
diff --git a/backend/utils/animal_rag.py b/backend/utils/animal_rag.py
index bfb04bd..35b611a 100644
--- a/backend/utils/animal_rag.py
+++ b/backend/utils/animal_rag.py
@@ -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({
diff --git a/docs/RX_USAGE_ORDER_DETAIL_IMPL.md b/docs/RX_USAGE_ORDER_DETAIL_IMPL.md
new file mode 100644
index 0000000..4937e63
--- /dev/null
+++ b/docs/RX_USAGE_ORDER_DETAIL_IMPL.md
@@ -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 시 툴팁 확인
diff --git a/docs/SUIN_API_FIX.md b/docs/SUIN_API_FIX.md
new file mode 100644
index 0000000..27bad2b
--- /dev/null
+++ b/docs/SUIN_API_FIX.md
@@ -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` 로직 적용됨