o2o-infinith-demo/scripts/sentiment_qb.py
Haewon Kam 5faf867b05 feat(measure): 네이버 AI 브리핑을 질문 뱅크 실측 엔진으로 추가
네이버 AI 브리핑은 공식 API가 없어 지금까지 답변 노출을 재지 못했다.
SerpApi naver_ai_overview(제3자 SERP API)로 조회하는 엔진을 붙였다(haewon 결정 2026-09-15).
search.naver.com robots.txt 가 수집을 금지하므로 고객 리포트·랜딩에는 넣지 않고
내부 측정까지만 반영했다. 고객 노출은 법무 확인 뒤에 따로 한다.

- run_question_bank_openai.py: --engine naver_briefing, --device, --no-cache(반복 측정 시 1시간 캐시 무시).
  브리핑이 없는 문항은 answer 를 비우고 briefing=false 로 기록. 인용 출처(sources) 보관
- monthly_ai_measure.py: 기본 엔진 3개(키 없으면 네이버 건너뜀),
  supporter_builds.report.naverBriefing(노출률·언급·출처 상위, legalReviewed=false)
- sentiment_qb.py: 빈 답변은 판정 API를 부르지 않고 미언급 처리
- merge_qb_results.py: NAVER 열·브리핑 없음 표시, 측정일을 생성일이 아닌 엔진별 실제 날짜로
- export_qb_excel.py: 네이버 열·NAVER상세 시트, --out 으로 기존 파일 보존.
  TOP50 점수는 기존 정의(ChatGPT+Perplexity, 만점 840) 유지

실측(뷰성형외과, 모바일, 2026-09-15): 브리핑 노출 119/120, 브랜드 질문 언급 36/36,
논브랜드 1/84. 인용 출처는 네이버 블로그 87·카페 51·굿닥 23·뷰 공식 20 순.
SerpApi 123회, 감성 판정 $0.37.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:55:59 +09:00

297 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"(?<![가-힣A-Za-z])" + re.escape(t) + r"(?![가-힣])") if re.match(r"[가-힣]", t) else r"\b" + re.escape(t) + r"\b"
if re.search(pat, a, re.I): return "weak"
return "none"
ASPECTS = ["doctors", "safety", "price", "reviews", "regulatory", "procedure", "access", "media"]
RECS = ["recommended", "listed", "cautioned", "not_applicable"]
def rubric_prompt(clinic, aliases):
names = ", ".join([clinic] + aliases)
return f"""당신은 AI 답변엔진의 답변에서 특정 병원이 어떤 어조로 언급됐는지 판정하는 심사자입니다.
대상 병원: {clinic} (별칭: {names}). 다른 병원에 대한 평가는 세지 않습니다. 대상 병원 문장만 봅니다.
판정 규칙
- mentioned: 대상 병원(별칭 포함)이 답변에 나오면 true.
- sentiment: positive(강점 서술·추천 근거) / neutral(사실 나열·목록 포함·안내) / negative(부정 사실·병원 고유 문제 지목·주의 권고) / mixed(긍정과 부정이 함께) / not_mentioned.
"확인이 필요하다", "직접 상담하라" 같은 일반 주의 문구는 neutral입니다. 병원 고유의 문제를 지목할 때만 negative입니다.
우선순위 규칙 (기준표 v0.2, 1차 보정에서 도출. 위 정의보다 우선합니다)
- R1. 결론 단락이 개별 항목보다 우선합니다. 답변에 종합 평가·결론 단락이 있으면 그 단락의 어조로 판정합니다. 본문에 나열된 개별 항목의 어조로 판정하지 않습니다.
예: 본문에 높은 평점이 있어도 결론이 "평판은 좋은 편이지만 추가 확인이 필요"면 neutral입니다.
예: 본문에 후기 지적 한 줄이 있어도 종합 평이 "안전성과 전문성을 갖춘 병원으로 평가받고 있습니다"면 positive입니다.
예: 결론이 "객관성까지 판단하긴 어렵고 부정적 의견도 일부 보입니다"면 negative입니다.
- R2. mixed는 긍정과 부정이 대등하게 병존할 때만 씁니다. 부정 성분이 문장 하나의 부수 언급이면 mixed로 세지 않습니다. 결론이 어느 쪽으로도 기울지 않을 때만 mixed입니다.
- R3. "후기 수가 많다", "인지도가 있다"는 규모 서술이며 긍정 평가가 아닙니다. 이 서술만 있고 병원 고유의 문제 지목이 함께 있으면 mixed가 아니라 negative입니다.
- R4. 평가 축이 있는 목록에 포함되면 positive입니다. 질문이 조건이나 우수성을 묻고(평점이 높은 곳, 마취과 전문의가 상주하는 곳, 잘하는 곳) 그 목록에 대상 병원이 들어가면 positive입니다. 조건 없는 단순 나열(위치·진료과목 안내, 지역 병원 열거)만 neutral입니다.
- R5. 강점·역량 서술은 출처가 병원 홍보라도 positive로 셉니다. 답변이 "안전과 만족을 최우선", "최신 기술과 장비", "마취과 전문의 365일 상주"처럼 강점을 서술하면, 그것이 병원 광고를 인용한 것이라도 어조는 positive입니다.
- negative_type: none / regulatory(공정위·시정명령·의료법 위반·광고 제재·소송) / safety(부작용·사고·안전) / service(응대·불친절) / price(가격 불만) / reputation(후기·평판 논란) / other.
- aspects: {", ".join(ASPECTS)} 중 해당하는 것 전부.
- evidence: 판정 근거가 된 답변 원문 문장 1개를 그대로 인용. 요약하지 않습니다. 언급이 없으면 빈 문자열.
- recommendation: recommended(추천했다) / listed(나열만) / cautioned(주의를 붙였다) / not_applicable.
- confidence: 0~1.
JSON만 출력합니다. 키: mentioned, sentiment, negative_type, aspects, evidence, recommendation, confidence."""
def judge(key, model, system, question, answer, retries=4):
body = {"model": model, "temperature": 0,
"response_format": {"type": "json_object"},
"messages": [{"role": "system", "content": system},
{"role": "user", "content": f"[질문]\n{question}\n\n[답변]\n{answer[:6000]}"}]}
req = urllib.request.Request("https://api.openai.com/v1/chat/completions", data=json.dumps(body).encode(),
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
for i in range(retries):
try:
with urllib.request.urlopen(req, timeout=90, context=ssl_context()) as r:
j = json.load(r)
out = json.loads(j["choices"][0]["message"]["content"])
usage = j.get("usage", {})
return out, usage
except urllib.error.HTTPError as e:
if e.code in (429, 500, 502, 503) and i < retries - 1:
time.sleep(2 * (i + 1)); continue
raise RuntimeError(f"HTTP {e.code}: {e.read()[:200]}")
except (json.JSONDecodeError, KeyError) as e:
if i < retries - 1: continue
raise RuntimeError(f"파싱 실패: {e}")
def normalize(o, mentioned_hint):
s = o.get("sentiment") if o.get("sentiment") in SENTIMENTS else "neutral"
m = bool(o.get("mentioned", mentioned_hint))
if not m: s = "not_mentioned"
return {"mentioned": m, "sentiment": s,
"negative_type": o.get("negative_type") if o.get("negative_type") in NEG_TYPES else ("none" if s not in ("negative", "mixed") else "other"),
"aspects": [a for a in (o.get("aspects") or []) if a in ASPECTS],
"evidence": str(o.get("evidence") or "")[:400],
"recommendation": o.get("recommendation") if o.get("recommendation") in RECS else ("not_applicable" if not m else "listed"),
"confidence": float(o.get("confidence") or 0)}
def run(args):
load_env()
key = os.environ.get("OPENAI_API_KEY") or sys.exit("OPENAI_API_KEY 필요 (.env)")
aliases = [a.strip() for a in (args.alias or "").split(",") if a.strip()]
weak_tokens = [w.strip() for w in (args.weak or "").split(",") if w.strip()]
system = rubric_prompt(args.clinic, aliases)
rows = [json.loads(l) for l in open(args.inp, encoding="utf-8") if l.strip()]
done = {}
if os.path.exists(args.out):
for l in open(args.out, encoding="utf-8"):
if l.strip(): r = json.loads(l); done[r["id"]] = r
print(f"입력 {len(rows)}건 · 이미 판정 {len(done)}건 · 모델 {args.model}")
tokens = collections.Counter()
with open(args.out, "a", encoding="utf-8") as f:
for i, r in enumerate(rows):
if r["id"] in done: continue
kind = mention_kind(r.get("answer", ""), args.clinic, aliases, weak_tokens)
hint = kind != "none"
if not (r.get("answer") or "").strip():
# 답변이 비어 있다(네이버 AI 브리핑이 뜨지 않은 질문 등). 판정할 문장이 없으므로 API를 부르지 않는다.
out, usage = {"mentioned": False}, {}
else:
out, usage = judge(key, args.model, system, r["question"], r.get("answer", ""))
n = normalize(out, hint)
# 탐지기가 none 이면 판정기 판단보다 우선한다. 상호가 아예 없는 답변을 언급으로 셀 수 없다.
if kind == "none" and n["mentioned"]:
n = normalize({"mentioned": False}, False)
# 약한 언급: 정식 상호 없이 영문 약칭만 나온 경우. 점수에는 넣되 표시하고 기준표 §6에서 포함 여부를 정한다
n["weak_mention"] = kind == "weak" and bool(n["mentioned"])
tokens["in"] += usage.get("prompt_tokens", 0); tokens["out"] += usage.get("completion_tokens", 0)
row = {"id": r["id"], "engine": r.get("engine"), "question": r["question"], "prior": r.get("prior"),
"extractor_mentioned": hint, "model": args.model, "rubric": RUBRIC_VERSION, **n, "ts": time.strftime("%Y-%m-%dT%H:%M:%S")}
f.write(json.dumps(row, ensure_ascii=False) + "\n"); f.flush()
print(f" {r['id']} {n['sentiment']:<13} {n['negative_type']:<10} {n['recommendation']:<14} {n['evidence'][:50]}")
time.sleep(0.3)
# gpt-4.1 단가 $2/M in · $8/M out
cost = tokens["in"] / 1e6 * 2 + tokens["out"] / 1e6 * 8
print(f"완료 → {args.out} · 토큰 in {tokens['in']} out {tokens['out']} · 약 ${cost:.2f}")
def score(rs):
m = [r for r in rs if r["mentioned"]]
if not m: return None
w = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
c = collections.Counter(r["sentiment"] for r in m)
return {"answers": len(rs), "mentioned": len(m),
"score": round(sum(w.get(r["sentiment"], 50) for r in m) / len(m), 1),
"positive": c["positive"], "neutral": c["neutral"], "mixed": c["mixed"], "negative": c["negative"],
"negative_rate": round((c["negative"] + c["mixed"]) / len(m) * 100, 1),
"regulatory_rate": round(sum(1 for r in m if r["negative_type"] == "regulatory") / len(m) * 100, 1),
"recommended_rate": round(sum(1 for r in m if r["recommendation"] == "recommended") / len(m) * 100, 1),
"aspects": collections.Counter(a for r in m for a in r["aspects"]).most_common(8)}
def summary(args):
allrows = []
for p in args.summary:
allrows += [json.loads(l) for l in open(p, encoding="utf-8") if l.strip()]
is_brand = lambda r: str(r["id"]).split("-")[0] in ("A", "B") # 질문 뱅크 A·B = 브랜드 질문
lines = [f"# AI 답변 감성 판정 결과 · {args.clinic}", "",
f"판정 {time.strftime('%Y-%m-%d')} · 기준표 {RUBRIC_VERSION}(`docs/AI_ANSWER_SENTIMENT_RUBRIC_{RUBRIC_VERSION}.md`) · 모델 {allrows[0]['model'] if allrows else ''} · 답변 {len(allrows)}", "",
"감성 점수 = (positive×100 + neutral×50 + mixed×25 + negative×0) ÷ 언급된 답변 수. 규제 언급률과 추천율은 언급된 답변 기준.", "",
"## 1. 엔진별", "", "| 엔진 | 질문군 | 답변 | 언급 | 감성 점수 | 긍정 | 중립 | 혼합 | 부정 | 부정 비율 | 규제 언급률 | 추천율 |", "|---|---|---|---|---|---|---|---|---|---|---|---|"]
engines = sorted({r["engine"] for r in allrows})
for e in engines:
for label, flt in (("전체", lambda r: True), ("브랜드(A·B)", is_brand), ("논브랜드", lambda r: not is_brand(r))):
s = score([r for r in allrows if r["engine"] == e and flt(r)])
if s: lines.append(f"| {e} | {label} | {s['answers']} | {s['mentioned']} | **{s['score']}** | {s['positive']} | {s['neutral']} | {s['mixed']} | {s['negative']} | {s['negative_rate']}% | {s['regulatory_rate']}% | {s['recommended_rate']}% |")
s = score(allrows)
if s: lines += ["", f"전체 합산: 언급 {s['mentioned']}/{s['answers']} · 감성 점수 **{s['score']}** · 부정 비율 {s['negative_rate']}% · 규제 언급률 {s['regulatory_rate']}% · 추천율 {s['recommended_rate']}%"]
# 약한 언급(정식 상호 없이 영문 약칭만)은 포함 여부가 미결이므로 제외한 점수를 함께 낸다. 기준표 §6.
weak = [r for r in allrows if r.get("weak_mention")]
if weak:
W = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
strong = [r for r in allrows if r["sentiment"] != "not_mentioned" and not r.get("weak_mention")]
ids = ", ".join(f"{r['id']}({r['engine']})" for r in weak)
lines += ["", f"약한 언급 {len(weak)}건({ids})은 정식 상호 없이 영문 약칭만 나온 답변이다. 이를 빼면 언급 {len(strong)}건 · 감성 점수 **{round(sum(W[r['sentiment']] for r in strong) / len(strong), 1)}**. 포함 여부는 기준표 §6 미결이다."]
lines += ["", "## 2. 측면 태그 (언급된 답변)", ""]
if s: lines += [f"- {a}: {n}" for a, n in s["aspects"]]
negs = [r for r in allrows if r["sentiment"] in ("negative", "mixed")]
lines += ["", f"## 3. 부정·혼합 판정 전건 ({len(negs)}건)", "", "| id | 엔진 | 판정 | 종류 | 질문 | 근거 문장 |", "|---|---|---|---|---|---|"]
for r in negs: lines.append(f"| {r['id']} | {r['engine']} | {r['sentiment']} | {r['negative_type']} | {r['question'][:40]} | {r['evidence'][:120]} |")
pos = [r for r in allrows if r["sentiment"] == "positive" and not is_brand(r)]
lines += ["", f"## 4. 논브랜드 질문에서 긍정 언급 ({len(pos)}건, 가장 가치 있는 신호)", "", "| id | 엔진 | 추천 | 질문 | 근거 문장 |", "|---|---|---|---|---|"]
for r in pos: lines.append(f"| {r['id']} | {r['engine']} | {r['recommendation']} | {r['question'][:40]} | {r['evidence'][:120]} |")
dis = [r for r in allrows if r["mentioned"] != r["extractor_mentioned"]]
lines += ["", f"## 5. 언급 판정 불일치 (판정기 vs 추출기) {len(dis)}", ""]
lines += [f"- {r['id']} {r['engine']}: 판정기 {r['mentioned']} / 추출기 {r['extractor_mentioned']} · {r['evidence'][:80]}" for r in dis[:30]]
lines += ["", "## 6. 측정 가능 범위", "",
"- 재는 것: 질문 뱅크 문항에 대한 공식 API 답변 안에서 대상 병원 문장의 어조. 엔진은 ChatGPT(gpt-4o + 웹 검색)·Perplexity(sonar)"
+ ("·네이버 AI 브리핑(SerpApi 제3자 SERP API, 브리핑이 없는 문항은 미언급으로 셈)" if "naver_briefing" in engines else "") + ".",
"- 재지 않는 것: 소비자 화면(chatgpt.com 등)의 실제 답변. 약관상 자동 조회를 하지 않으므로 API 답변과 화면 답변은 다를 수 있다.",
"- 재지 않는 것: Google AI Overview·네이버 AI 브리핑·Gemini 의 어조. 다음 회차에 엔진을 추가할 때 같은 기준표로 잰다.",
"- 한계: 판정기는 언어 모델이다. 보정(§7) 전 점수는 참고값이고, 보정 후에도 문항 수(언급 84건)가 적어 ±10점은 우연 범위로 본다."]
# --calibration 없이 리포트만 다시 만드는 경로가 정상 사용이다(haewon 라벨을 지우지 않기 위해).
# 그때는 새 보정 표본을 만들지 않았으므로 1차 보정 결과를 그대로 적는다.
if args.calibration:
lines += ["", "## 7. 보정", "", f"무작위 30건을 `{os.path.basename(args.calibration)}`에 담았다(판정기 값은 숨김 시트). haewon이 sentiment·negative_type을 적으면 `--agreement` 로 일치율을 낸다. 80% 이상이면 기준표 확정."]
else:
lines += ["", "## 7. 보정", "",
f"기준표 `{RUBRIC_VERSION}` 로 판정했다. 1차 보정(2026-09-09)에서 haewon 라벨 29건 대비 v0.1 일치율은 75%로 기준선 80%에 미달했고, 불일치 7건에서 규칙 R1~R5를 뽑아 판정기에 반영한 것이 이 회차다.",
"",
"같은 30건으로 다시 잰 일치율은 규칙을 그 표본에 맞춰 고친 결과이므로 확정 근거가 아니다. 기준표 확정은 새로 뽑은 30건의 2차 보정으로 한다. 자세한 이력과 한계는 `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.2.md` §5에 있다."]
os.makedirs(os.path.dirname(args.report), exist_ok=True)
open(args.report, "w", encoding="utf-8").write("\n".join(lines) + "\n")
print(f"리포트 → {args.report}")
if args.calibration: write_calibration(allrows, args.calibration)
def write_calibration(rows, path):
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
except ImportError:
sys.exit("openpyxl 필요: pip install openpyxl")
random.seed(20260908)
m = [r for r in rows if r["mentioned"]]
sample = random.sample(m, min(30, len(m)))
wb = Workbook(); ws = wb.active; ws.title = "라벨링"
# 답변 전문을 붙인다(근거 문장만 보고 판정하면 안 된다). 원본 QB 결과에서 id+engine 으로 찾는다.
full = {}
for eng in ("openai", "perplexity"):
fp = os.path.join(ROOT, "scripts", "out", f"qb_{eng}_results.jsonl")
if os.path.exists(fp):
for l in open(fp, encoding="utf-8"):
if l.strip(): q = json.loads(l); full[(q["id"], eng)] = q.get("answer", "")
head = ["id", "엔진", "질문", "답변 전문", "판정기가 인용한 근거 문장", "haewon sentiment\n(positive / neutral / negative / mixed / not_mentioned)", "haewon negative_type\n(none / regulatory / safety / service / price / reputation / other)", "메모"]
ws.append(head)
for c in ws[1]: c.font = Font(bold=True, color="FFFFFF"); c.fill = PatternFill("solid", fgColor="0A1128"); c.alignment = Alignment(wrap_text=True, vertical="top")
ws.row_dimensions[1].height = 48
for r in sample: ws.append([r["id"], r["engine"], r["question"], full.get((r["id"], r["engine"]), ""), r["evidence"], "", "", ""])
for col, w in zip("ABCDEFGH", (8, 11, 34, 90, 46, 22, 24, 24)): ws.column_dimensions[col].width = w
from openpyxl.worksheet.datavalidation import DataValidation
dv1 = DataValidation(type="list", formula1='"positive,neutral,negative,mixed,not_mentioned"', allow_blank=True); dv2 = DataValidation(type="list", formula1='"none,regulatory,safety,service,price,reputation,other"', allow_blank=True)
ws.add_data_validation(dv1); ws.add_data_validation(dv2); dv1.add(f"F2:F{len(sample)+1}"); dv2.add(f"G2:G{len(sample)+1}")
ws.freeze_panes = "D2"
guide = wb.create_sheet("기준 요약")
for row in [["", "언제"], ["positive", "병원의 강점을 서술하거나 추천 근거로 든다"], ["neutral", "사실 나열, 목록 포함, 위치·진료과목 안내. '확인이 필요하다', '직접 상담하라' 같은 일반 주의 문구도 neutral"], ["negative", "부정 사실을 서술하거나 병원 고유의 문제를 지목해 주의를 권한다(공정위, 부작용 사례, 후기 논란)"], ["mixed", "긍정과 부정이 한 답변에 함께 있다"], ["", ""], ["negative_type", "부정·혼합일 때만. regulatory 공정위·시정명령·의료법 / safety 부작용·사고 / service 응대 / price 가격 불만 / reputation 후기·평판 논란 / other. 긍정·중립이면 none"], ["", ""], ["주의", "다른 병원에 대한 평가는 세지 않는다. 뷰성형외과 문장만 본다. 답변 전문(D열)을 읽고 판단한다. 근거 문장(E열)은 참고만"]]: guide.append(row)
guide.column_dimensions["A"].width = 16; guide.column_dimensions["B"].width = 110
for row in guide.iter_rows():
for c in row: c.alignment = Alignment(wrap_text=True, vertical="top")
for row in ws.iter_rows(min_row=2):
for c in row: c.alignment = Alignment(wrap_text=True, vertical="top")
hid = wb.create_sheet("판정기(숨김)"); hid.sheet_state = "hidden"
hid.append(["id", "engine", "sentiment", "negative_type", "recommendation", "confidence"])
for r in sample: hid.append([r["id"], r["engine"], r["sentiment"], r["negative_type"], r["recommendation"], r["confidence"]])
os.makedirs(os.path.dirname(path), exist_ok=True); wb.save(path)
print(f"보정 샘플 {len(sample)}건 → {path}")
def agreement(args):
from openpyxl import load_workbook
wb = load_workbook(args.agreement)
human = {(r[0], r[1]): (str(r[5] or "").strip().lower(), str(r[6] or "").strip().lower()) for r in wb["라벨링"].iter_rows(min_row=2, values_only=True) if r[0]}
model = {(r[0], r[1]): (r[2], r[3]) for r in wb["판정기(숨김)"].iter_rows(min_row=2, values_only=True) if r[0]}
labeled = [k for k, v in human.items() if v[0]]
if not labeled: sys.exit("haewon 라벨이 비어 있습니다")
s_ok = sum(1 for k in labeled if human[k][0] == model[k][0]); n_ok = sum(1 for k in labeled if human[k][1] and human[k][1] == model[k][1])
n_lab = sum(1 for k in labeled if human[k][1])
print(f"라벨 {len(labeled)}건 · sentiment 일치 {s_ok}/{len(labeled)} = {s_ok*100//len(labeled)}% · negative_type 일치 {n_ok}/{n_lab}")
for k in labeled:
if human[k][0] != model[k][0]: print(f" 불일치 {k[0]} {k[1]}: haewon {human[k][0]} / 판정기 {model[k][0]}")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--clinic", default="뷰성형외과"); ap.add_argument("--alias", default="뷰성형외과의원,뷰 성형외과,View Plastic Surgery,viewclinic")
ap.add_argument("--weak", default="View,뷰", help="정식 상호 없이 이것만 나오면 약한 언급으로 표시한다")
ap.add_argument("--in", dest="inp"); ap.add_argument("--out"); ap.add_argument("--model", default="gpt-4.1")
ap.add_argument("--summary", nargs="*"); ap.add_argument("--report"); ap.add_argument("--calibration"); ap.add_argument("--agreement")
a = ap.parse_args()
if a.agreement: agreement(a)
elif a.summary: summary(a)
elif a.inp and a.out: run(a)
else: ap.print_help()