fix: rx-usage 쿼리에 PS_Type!=9 조건 추가 (실제 조제된 약만 집계)

- patient_query: 대체조제 원본 처방 제외
- rx_query: 대체조제 원본 처방 제외
- PS_Type=9는 대체조제시 원래 처방된 약(조제 안됨)
- 기타 배치 스크립트 및 문서 추가
This commit is contained in:
thug0bin
2026-03-09 21:54:32 +09:00
parent f92abf94c8
commit e470deaefc
10 changed files with 1909 additions and 121 deletions

View File

@@ -80,6 +80,9 @@ app.register_blueprint(sooin_bp)
from baekje_api import baekje_bp
app.register_blueprint(baekje_bp)
from dongwon_api import dongwon_bp
app.register_blueprint(dongwon_bp)
from wholesaler_config_api import wholesaler_config_bp
app.register_blueprint(wholesaler_config_bp)
@@ -2946,6 +2949,11 @@ ANIMAL_DRUG_KNOWLEDGE = """
- 콜리/셸티/보더콜리 등 MDR1 유전자 변이견은 이버멕틴 주의
- 임신/수유 중인 동물은 수의사 상담 필요
- 체중 정확히 측정 후 제품 선택
## 🚨 항생제 필수 경고 (퀴놀론계)
- **엔로플록사신(아시엔로, Baytril)**: 🐱 고양이 망막 독성! 5mg/kg/day 초과 시 실명 위험. 대안: 마르보플록사신
- **이버멕틴 고용량**: MDR1 유전자 변이견(콜리, 셸티, 오스트레일리안 셰퍼드) 신경독성 주의
- **어린 동물 퀴놀론계**: 연골 발달에 영향, 성장기 동물 주의
"""
# 동물약 챗봇 System Prompt
@@ -2977,6 +2985,13 @@ ANIMAL_CHAT_SYSTEM_PROMPT = """당신은 약국의 동물약 전문 상담사입
- 일반적 비교 설명 + 우리 약국 보유 여부 안내
- 길게 상세히 (10-15문장)
**⚠️ 투여방법 구분 (필수!):**
- "먹는 약", "경구", "복용" 질문 → 내복약만 추천 (정제, 츄어블, 캡슐, 시럽)
- "바르는 약", "도포", "외용" 질문 → 외용약만 추천 (겔, 스팟온, 크림, 연고)
- RAG 정보의 "제형", "분류", "체중/부위" 필드 확인 필수
- 외용약(겔, 도포, 환부에 직접)은 절대 "먹는 약"으로 추천하지 않음!
- 보유 제품 목록의 [내복/외용] 표시 확인!
**기본 규칙:**
1. 체중별 제품은 정확한 전체 이름 사용 (안텔민킹, 안텔민뽀삐 등)
2. 용량/투약 질문: 체중별 표 형식으로 정리
@@ -3008,6 +3023,8 @@ def _get_animal_drug_rag(apc_codes):
image_url1,
llm_pharm->>'사용가능 동물' as target_animals,
llm_pharm->>'분류' as category,
llm_pharm->>'쉬운분류' as easy_category,
llm_pharm->>'약품 제형' as dosage_form,
llm_pharm->>'체중/부위' as dosage_weight,
llm_pharm->>'기간/용법' as usage_period,
llm_pharm->>'월령금기' as age_restriction,
@@ -3024,6 +3041,8 @@ def _get_animal_drug_rag(apc_codes):
rag_data[row.apc] = {
'target_animals': row.target_animals or '정보 없음',
'category': row.category or '',
'easy_category': row.easy_category or '',
'dosage_form': row.dosage_form or '',
'dosage_weight': row.dosage_weight or '',
'usage_period': row.usage_period or '',
'age_restriction': row.age_restriction or '',
@@ -3208,6 +3227,28 @@ def api_animal_chat():
if d.get('apc') and d['apc'] in rag_data:
info = rag_data[d['apc']]
details = []
# 투여방법 표시 (내복/외용 구분)
admin_type = ""
dosage_form = info.get('dosage_form', '').lower()
easy_cat = info.get('easy_category', '').lower()
dosage_weight = info.get('dosage_weight', '').lower()
# 외용약 판별 (겔, 크림, 연고, 스팟온, 도포, 환부)
if any(kw in dosage_form for kw in ['', '크림', '연고', '스팟온', '점이', '외용']) or \
any(kw in easy_cat for kw in ['외용', '피부약', '점이', '점안']) or \
any(kw in dosage_weight for kw in ['도포', '환부', '바르']):
admin_type = "외용"
# 내복약 판별 (정제, 츄어블, 캡슐, 시럽, 경구)
elif any(kw in dosage_form for kw in ['정제', '츄어블', '캡슐', '시럽', '산제', '과립', '액제', '경구']) or \
any(kw in easy_cat for kw in ['내복', '경구', '구충', '심장사상충', '정장', '소화']):
admin_type = "내복"
if admin_type:
form_display = info.get('dosage_form', '')[:10] if info.get('dosage_form') else ''
cat_display = info.get('easy_category', '')[:15] if info.get('easy_category') else ''
details.append(f"💊{admin_type}/{form_display}, {cat_display}")
if info.get('target_animals'):
details.append(f"대상: {info['target_animals']}")
if info.get('main_ingredient'):
@@ -3231,6 +3272,37 @@ def api_animal_chat():
detail_keywords = ['자세히', '상세히', '더 알려', '설명해', '', '어떻게', '원리', '기전', '성분']
is_detail_request = any(kw in last_user_msg for kw in detail_keywords)
# 투여방법 키워드 감지
oral_keywords = ['먹는', '먹이는', '경구', '복용', '츄어블', '정제', '캡슐']
topical_keywords = ['바르는', '도포', '외용', '스팟온', '', '크림', '연고']
is_oral_request = any(kw in last_user_msg for kw in oral_keywords)
is_topical_request = any(kw in last_user_msg for kw in topical_keywords)
# 투여방법 필터 안내 추가
admin_filter_note = ""
if is_oral_request and not is_topical_request:
admin_filter_note = "\n\n⚠️ **사용자가 '먹는 약/경구/복용' 질문** → 내복약(정제, 츄어블, 캡슐)만 추천! 외용약(겔, 크림, 도포)은 추천하지 마세요!"
elif is_topical_request and not is_oral_request:
admin_filter_note = "\n\n⚠️ **사용자가 '바르는 약/외용/도포' 질문** → 외용약(겔, 크림, 스팟온)만 추천! 내복약(정제, 츄어블)은 추천하지 마세요!"
# 🚨 엔로플록사신/퀴놀론 관련 질문 감지 → 망막 독성 경고 주입
enro_keywords = ['엔로플록사신', '아시엔로', 'asienro', 'enrofloxacin', '퀴놀론', 'fluoroquinolone', 'baytril', '바이트릴']
is_enro_question = any(kw.lower() in last_user_msg.lower() for kw in enro_keywords)
enro_warning = ""
if is_enro_question:
enro_warning = """
🚨 **[필수 안내] 엔로플록사신 고양이 망막 독성 - 반드시 답변에 포함!**
- FDA 부작용 보고: 산동 454건, 실명 399건, 망막장애 244건
- 고양이는 **5mg/kg/day 절대 초과 금지** (권장: 2.5~5mg/kg)
- 15mg/kg/day × 21일 → 망막 이상, 30mg/kg/day → 급성 실명
- 탈수/신장질환 시 위험 증가 (약물 배설 지연)
- 안전한 대안: Marbofloxacin(마르보플록사신), Pradofloxacin(프라도플록사신)
- ⚠️ 이 경고를 답변 첫 부분에 반드시 언급하세요!
"""
# 벡터 DB 검색 (LanceDB RAG)
vector_context = ""
vector_start = time.time()
@@ -3254,9 +3326,13 @@ def api_animal_chat():
log_entry.vector_duration_ms = int((time.time() - vector_start) * 1000)
# System Prompt 구성
knowledge_section = ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE
knowledge_section += admin_filter_note # 투여방법 필터 안내 추가
knowledge_section += enro_warning # 🚨 엔로플록사신 망막 독성 경고 추가
system_prompt = ANIMAL_CHAT_SYSTEM_PROMPT.format(
available_products=available_products_text,
knowledge_base=ANIMAL_DRUG_KNOWLEDGE + "\n\n" + vector_context if vector_context else ANIMAL_DRUG_KNOWLEDGE
knowledge_base=knowledge_section
)
# OpenAI API 호출
@@ -4251,6 +4327,7 @@ def api_rx_usage():
mssql_session = db_manager.get_session('PM_PRES')
# 1년간 사용 환자 3명 이하 약품의 환자 목록 조회 + 조회 기간 내 사용 여부
# PS_Type: 0,1=일반, 4=대체조제(실제), 9=대체조제(원본) - 9는 제외해야 실제 조제된 약만 집계
patient_query = text("""
WITH PatientUsage AS (
SELECT DISTINCT
@@ -4260,6 +4337,7 @@ def api_rx_usage():
FROM PS_sub_pharm P
JOIN PS_main M ON P.PreSerial = M.PreSerial
WHERE M.Indate >= CONVERT(VARCHAR, DATEADD(YEAR, -1, GETDATE()), 112)
AND (P.PS_Type IS NULL OR P.PS_Type != '9')
GROUP BY P.DrugCode, M.Paname
)
SELECT
@@ -4318,6 +4396,7 @@ def api_rx_usage():
orders_conn.close()
# 전문의약품 품목별 사용량 집계 쿼리 (현재고: IM_total.IM_QT_sale_debit, 위치: CD_item_position.CD_NM_sale)
# PS_Type != '9' 조건: 대체조제 원본 처방 제외 → 실제 조제된 약만 집계
rx_query = text("""
SELECT
P.DrugCode as drug_code,
@@ -4336,6 +4415,7 @@ def api_rx_usage():
LEFT JOIN PM_DRUG.dbo.CD_item_position POS ON P.DrugCode = POS.DrugCode
WHERE P.Indate >= :start_date
AND P.Indate <= :end_date
AND (P.PS_Type IS NULL OR P.PS_Type != '9')
GROUP BY P.DrugCode, G.GoodsName, G.SplName, G.BARCODE, IT.IM_QT_sale_debit, POS.CD_NM_sale
ORDER BY SUM(ISNULL(P.QUAN, 1) * ISNULL(P.Days, 1)) DESC
""")
@@ -7301,10 +7381,30 @@ def api_animal_drug_info_print():
LIMIT 1
"""), {'apc': apc})
row = result.fetchone()
# 포장단위 APC → 대표 APC 폴백
if not row and len(apc) == 13 and apc.startswith('023'):
item_prefix = apc[:8]
result = conn.execute(text("""
SELECT
a.product_name, a.company_name, a.main_ingredient,
a.efficacy_effect, a.dosage_instructions, a.precautions,
a.weight_min_kg, a.weight_max_kg, a.pet_size_label,
a.component_code,
g.component_name_ko, g.dosing_interval_adult,
g.dosing_interval_high_risk, g.dosing_interval_puppy,
g.companion_drugs
FROM apc a
LEFT JOIN component_guide g ON a.component_code = g.component_code
WHERE a.apc LIKE :prefix
ORDER BY LENGTH(a.apc)
LIMIT 1
"""), {'prefix': f'{item_prefix}%'})
row = result.fetchone()
if not row:
return jsonify({'success': False, 'error': f'APC {apc} 정보를 찾을 수 없습니다'}), 404
except Exception as e:
logging.error(f"PostgreSQL 조회 오류: {e}")
return jsonify({'success': False, 'error': f'DB 조회 오류: {str(e)}'}), 500
@@ -7320,8 +7420,8 @@ def api_animal_drug_info_print():
# HTML 엔티티 변환
text = unescape(text)
# 표 형식 감지 (─ 문자 포함)
if '' in text or '' in text:
# 표 형식 감지 (─ 또는 ====/---- 포함)
if '' in text or '' in text or ('======' in text and '------' in text):
# 표 형식: 각 줄의 앞뒤 공백만 정리, 줄 내 공백은 유지
lines = text.split('\n')
cleaned = []
@@ -7409,13 +7509,26 @@ def api_animal_drug_info_print():
{THIN}
▶ 용법용량
"""
# 표 형식 감지 (─ 문자 포함)
if '' in dosage or '' in dosage:
# 표 형식: 줄바꿈 유지, 공백 유지
# 표 형식 감지
has_box_table = '' in dosage or '' in dosage
has_ascii_table = '======' in dosage and '------' in dosage
if has_box_table:
# ─ 표: 줄바꿈 유지
for line in dosage.split('\n'):
line = line.strip()
if line:
message += f"{line}\n"
elif has_ascii_table:
# ===/--- 표: 구분선 제거, 데이터만 정리
for line in dosage.split('\n'):
stripped = line.strip()
if not stripped:
continue
if stripped.startswith('===') or stripped.startswith('---'):
message += f" {'' * 44}\n"
else:
# 공백 정렬된 열을 적절히 정리
message += f" {stripped}\n"
else:
formatted_dosage = format_for_print(dosage)
for para in formatted_dosage.split('\n'):
@@ -7518,7 +7631,27 @@ def api_animal_drug_info_preview():
LIMIT 1
"""), {'apc': apc})
row = result.fetchone()
# 포장단위 APC → 대표 APC 폴백 (앞 8자리 품목코드로 검색)
if not row and len(apc) == 13 and apc.startswith('023'):
item_prefix = apc[:8]
result = conn.execute(text("""
SELECT
a.product_name, a.company_name, a.main_ingredient,
a.efficacy_effect, a.dosage_instructions, a.precautions,
a.component_code,
g.component_name_ko, g.dosing_interval_adult,
g.dosing_interval_high_risk, g.dosing_interval_puppy,
g.companion_drugs,
g.contraindication as guide_contraindication
FROM apc a
LEFT JOIN component_guide g ON a.component_code = g.component_code
WHERE a.apc LIKE :prefix
ORDER BY LENGTH(a.apc)
LIMIT 1
"""), {'prefix': f'{item_prefix}%'})
row = result.fetchone()
if not row:
return jsonify({'success': False, 'error': f'APC {apc} 정보 없음'}), 404
@@ -7534,8 +7667,8 @@ def api_animal_drug_info_preview():
text = re.sub(r'<[^>]+>', '', text)
text = unescape(text)
# 표 형식 감지
if '' in text or '' in text:
# 표 형식 감지 (─ 또는 ====/---- 포함)
if '' in text or '' in text or ('======' in text and '------' in text):
lines = [l.strip() for l in text.split('\n') if l.strip()]
return '\n'.join(lines)
else:
@@ -7556,49 +7689,175 @@ def api_animal_drug_info_preview():
def format_table_html(text):
if not text:
return ''
has_box_line = '' in text or '' in text
has_ascii_table = '======' in text and '------' in text
# 표가 없으면 일반 처리
if '' not in text and '' not in text:
if not has_box_line and not has_ascii_table:
return format_items(text)
# 표 형식: 체중별 투여량 테이블 생성
lines = [l.strip() for l in text.split('\n') if l.strip() and '' not in l and '' not in l]
# 체중 행과 투여정수 행 찾기
header_line = None
data_line = None
other_lines = []
for line in lines:
if '체중' in line and 'kg' in line.lower():
header_line = line
elif '투여' in line and '정수' in line:
data_line = line
# ── (A) 안텔민 형식: ─ 구분 + "체중(kg)" 헤더 + "투여정수" 데이터 (2행 표) ──
if has_box_line:
lines = [l.strip() for l in text.split('\n') if l.strip() and '' not in l and '' not in l]
header_line = None
data_line = None
other_lines = []
for line in lines:
if '체중' in line and 'kg' in line.lower():
header_line = line
elif '투여' in line and ('정수' in line or '' in line):
data_line = line
else:
other_lines.append(line)
result = '\n'.join(other_lines)
if header_line and data_line:
headers = re.split(r'\s{2,}', header_line)
values = re.split(r'\s{2,}', data_line)
html = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in headers:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
html += '<tr style="background:#fff;">'
for v in values:
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
html += '</tr>'
html += '</table>'
result = result + '\n' + html if result else html
return result
# ── (B) 넥스가드 형식: ====/---- 구분 + 다행 테이블 ──
if has_ascii_table:
lines = text.split('\n')
before_table = []
table_rows = []
after_table = []
header_cols = []
in_table = False
table_ended = False
for line in lines:
stripped = line.strip()
if not stripped:
continue
is_eq_sep = stripped.startswith('===')
is_dash_sep = stripped.startswith('---')
if is_eq_sep:
if in_table:
# 두 번째 === → 테이블 끝
table_ended = True
else:
# 첫 번째 === → 테이블 시작
in_table = True
continue
if is_dash_sep:
# --- 는 행 구분선 → 건너뛰기
continue
if not in_table:
before_table.append(stripped)
continue
if table_ended:
after_table.append(stripped)
continue
# ㅣ 또는 | 구분자 감지
if '' in stripped or '|' in stripped:
sep = '' if '' in stripped else '|'
cells = [c.strip() for c in stripped.split(sep) if c.strip()]
else:
cells = re.split(r'\s{2,}', stripped)
# 테이블 헤더 행 감지
if '체중' in stripped and not header_cols:
header_cols = cells
continue
# 데이터 행
if len(cells) >= 2:
table_rows.append(cells)
result_parts = []
if before_table:
result_parts.append(format_items('\n'.join(before_table)))
if header_cols and table_rows:
html = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in header_cols:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
for i, row in enumerate(table_rows):
bg = '#fff' if i % 2 == 0 else '#f8fafc'
html += f'<tr style="background:{bg};">'
for cell in row:
html += f'<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">{cell}</td>'
# 셀 수가 헤더보다 적으면 빈 셀 채우기
for _ in range(len(header_cols) - len(row)):
html += '<td style="border:1px solid #93c5fd;padding:6px 8px;"></td>'
html += '</tr>'
html += '</table>'
result_parts.append(html)
if after_table:
result_parts.append(format_items('\n'.join(after_table)))
return '\n'.join(result_parts)
return format_items(text)
def format_dosage(raw_html):
"""dosage_instructions 처리: 원본 HTML table 보존 or 텍스트 표 변환"""
if not raw_html:
return ''
# 원본에 <table> 태그가 있으면 → HTML 테이블 보존 + 스타일 적용
if '<table' in raw_html:
# table 앞뒤 텍스트 분리
before_html = re.split(r'<div[^>]*class="_table_wrap', raw_html, maxsplit=1)[0] if '_table_wrap' in raw_html else raw_html.split('<table')[0]
after_match = re.search(r'</table>(.*)', raw_html, re.DOTALL)
after_html = after_match.group(1) if after_match else ''
# 앞부분 텍스트 처리
before_text = strip_html(before_html)
before_text = format_items(before_text) if before_text else ''
# 테이블 추출 및 스타일 적용
table_match = re.search(r'<table[^>]*>(.*?)</table>', raw_html, re.DOTALL)
if table_match:
table_inner = table_match.group(1)
# caption, hidden 요소 제거
table_inner = re.sub(r'<caption>.*?</caption>', '', table_inner, flags=re.DOTALL)
# 기존 style 제거하고 새 스타일 적용
table_inner = re.sub(r'<td[^>]*>', '<td style="border:1px solid #93c5fd;padding:6px 8px;text-align:center;font-size:12px;">', table_inner)
table_inner = re.sub(r'<th[^>]*>', '<th style="border:1px solid #93c5fd;padding:8px;text-align:center;background:#dbeafe;">', table_inner)
# 첫 번째 tr에 헤더 배경색 적용
table_inner = re.sub(r'<tr[^>]*>', '<tr>', table_inner)
table_inner = table_inner.replace('<tr>', '<tr style="background:#dbeafe;">', 1)
# p 태그 제거 (셀 내부)
table_inner = re.sub(r'<p[^>]*>', '', table_inner)
table_inner = re.sub(r'</p>', '<br>', table_inner)
# tbody 유지
styled_table = f'<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">{table_inner}</table>'
else:
other_lines.append(line)
result = '\n'.join(other_lines)
# HTML 테이블 생성
if header_line and data_line:
headers = re.split(r'\s{2,}', header_line)
values = re.split(r'\s{2,}', data_line)
html = '<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">'
html += '<tr style="background:#dbeafe;">'
for h in headers:
html += f'<th style="border:1px solid #93c5fd;padding:8px;text-align:center;">{h}</th>'
html += '</tr>'
html += '<tr style="background:#fff;">'
for v in values:
html += f'<td style="border:1px solid #93c5fd;padding:8px;text-align:center;font-weight:bold;color:#1e40af;">{v}</td>'
html += '</tr>'
html += '</table>'
result = result + '\n' + html if result else html
return result
styled_table = ''
# 뒷부분 텍스트 처리
after_text = strip_html(after_html)
after_text = format_items(after_text) if after_text else ''
parts = [p for p in [before_text, styled_table, after_text] if p]
return '\n'.join(parts)
# 원본에 <table> 없으면 기존 로직
return format_table_html(strip_html(raw_html))
# 투약주기 조합
dosing_interval = None
if row.dosing_interval_adult:
@@ -7617,8 +7876,8 @@ def api_animal_drug_info_preview():
'company_name': row.company_name,
'main_ingredient': row.main_ingredient if row.main_ingredient != 'NaN' else None,
'efficacy_effect': format_items(strip_html(row.efficacy_effect)),
'dosage_instructions': format_table_html(strip_html(row.dosage_instructions)),
'dosage_has_table': '' in (row.dosage_instructions or ''),
'dosage_instructions': format_dosage(row.dosage_instructions),
'dosage_has_table': any(c in (row.dosage_instructions or '') for c in ('', '', '======', '<table')),
'precautions': format_items(strip_html(row.precautions)),
# 성분 가이드 (component_guide JOIN)
'component_code': row.component_code,