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 = `
+
도매상별 주문
`; + + for (const detail of order.details) { + tooltipHtml += ` +
+ + + ${detail.name} + + ${detail.units.toLocaleString()} +
`; + } + + tooltipHtml += ` +
+ 합계 + ${order.units.toLocaleString()} +
+
`; + + 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` 로직 적용됨