#!/usr/bin/env python3 """AI 답변 감성 판정기. 질문 뱅크 실측 결과(JSONL, 답변 원문 포함)를 읽어 대상 병원 언급의 어조를 판정한다. 기준표: docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.3.md · 판정 모델: OpenAI gpt-4.1 (JSON 출력) 사용: python3 scripts/sentiment_qb.py --clinic 뷰성형외과 --alias "뷰성형외과의원,뷰 성형외과,View Plastic Surgery" \ --in scripts/out/qb_openai_results.jsonl --out scripts/out/qb_sentiment_openai.jsonl python3 scripts/sentiment_qb.py --summary scripts/out/qb_sentiment_openai.jsonl scripts/out/qb_sentiment_perplexity.jsonl \ --report docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md --calibration docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment_calibration.xlsx - 재개 가능: out 파일에 이미 있는 id는 건너뛴다. - 비용: 답변당 1회 호출, 240건 약 $1.5. - 소비자 UI 자동 조회는 하지 않는다(약관). 공식 API 답변 원문만 판정한다. """ import argparse, json, os, random, re, ssl, sys, time, urllib.request, urllib.error, collections def ssl_context(): # 프레임워크 Python 은 인증서 저장소가 비어 CERTIFICATE_VERIFY_FAILED 가 난다. certifi 가 있으면 그것을, 없으면 시스템 기본을 쓴다. try: import certifi; return ssl.create_default_context(cafile=certifi.where()) except ImportError: return ssl.create_default_context() ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) def load_env(): for d in (ROOT, os.path.join(ROOT, "..")): p = os.path.join(d, ".env") if not os.path.exists(p): continue for line in open(p, encoding="utf-8"): if "=" in line and not line.strip().startswith("#"): k, v = line.split("=", 1); os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) SENTIMENTS = ["positive", "neutral", "negative", "mixed", "not_mentioned"] NEG_TYPES = ["none", "regulatory", "safety", "service", "price", "reputation", "other"] # 기준표 버전. 판정 결과의 추적용. docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.2.md 와 같이 움직인다. # v0.2: 1차 보정(29건, 일치 75%) 불일치에서 R1~R5 추가. # v0.3: 규칙은 R1~R5에서 멈춘다. R4-a·R6을 시험 투입했으나 haewon 라벨 대비 일치율이 93%에서 89%로 # 떨어져(하나 고치면 하나 깨짐) 되돌렸다. n=29에서는 과적합이다. 기준표 §5 참조. # v0.3의 실제 변경은 결정론적 언급 탐지기(mention_kind)뿐이다. URL 안의 상호와 '리뷰'의 '뷰' 오탐을 없앤다. RUBRIC_VERSION = "v0.3" URL_RE = re.compile(r"https?://\S+|\([a-z0-9.-]+\.[a-z]{2,}[^)]*\)", re.I) def strip_urls(text): """URL·인용 링크를 지운다. news.nate.com/view/... 의 'view' 를 병원 언급으로 세지 않기 위해서다.""" return URL_RE.sub(" ", text or "") def _flex(name): """문자 사이 공백을 허용하는 패턴. '뷰성형외과' 가 '뷰 성형외과' 로 쓰여도 잡는다.""" return r"\s*".join(re.escape(c) for c in name if not c.isspace()) def mention_kind(answer, clinic, aliases, weak_tokens): """strong 정식 상호 / weak 약칭만 / none 언급 없음. URL 을 먼저 지우고, 한글 약칭은 '리뷰' 의 '뷰' 처럼 다른 낱말의 일부인 경우를 뺀다.""" a = strip_urls(answer) for n in [clinic] + list(aliases): if n and re.search(_flex(n), a, re.I): return "strong" for t in weak_tokens: pat = (r"(?