o2o-infinith-demo/scripts/sentiment_qb.py
Haewon Kam 2eb3d5cd06 feat(sentiment): 감성 판정 기준표 v0.3 — 1차 보정 반영, 결정론적 언급 탐지기, 월간 실측 체인
haewon 보정 라벨 29건으로 1차 보정을 마쳤다. sentiment 일치율이 75%로 기준선 80%에
미달해 불일치 7건에서 규칙 R1~R5를 뽑아 기준표와 판정기 프롬프트에 넣었다.

규칙 (기준표 §2)
- R1 결론 단락이 개별 항목보다 우선한다
- R2 mixed 는 긍정과 부정이 대등하게 병존할 때만
- R3 "후기 수가 많다"는 규모 서술이며 긍정 평가가 아니다
- R4 평가 축이 있는 목록에 포함되면 positive
- R5 강점 서술은 출처가 병원 홍보라도 positive

R4-a(목록에서 결론이 대상 병원을 빼면 positive 아님)와 R6(결론 없는 카탈로그는
neutral)은 시험 투입 후 철회했다. 일치율이 93%에서 89%로 떨어졌다. 하나 고치면
하나 깨진다. n=29 에서는 과적합이므로 R1~R5 에서 멈춘다.

결정론적 언급 탐지기 (기준표 §2-1, mention_kind)
sentiment 를 매기기 전에 상호 존재를 정규식으로 먼저 판정하고, 판정기가 언급이라
해도 탐지기가 없다고 하면 not_mentioned 로 덮는다. 언급 수가 모든 비율의 분모다.
- URL 을 먼저 지운다. news.nate.com/view/... 의 "view" 를 언급으로 세던 오탐 3건 제거
- "리뷰"의 "뷰" 처럼 다른 낱말의 일부인 약칭을 뺀다. G-04 오탐 제거
- 한글 상호의 음절 사이 공백을 허용한다. "뷰 성형외과" 미탐지 2건 해소
언급 84 → 83, 약한 언급 3 → 1.

수치 (뷰성형외과 240건)
감성 점수 53.6 → 58.4. 약한 언급 1건 제외 시 57.9. mixed 10 → 0.
haewon 라벨 대비 일치율 93%는 같은 표본으로 규칙을 고친 값이라 확정 근거가 아니다.
기준표 확정은 새 30건 2차 보정으로 한다. 리포트와 요약 JSON 에 confirmed=false 로 적었다.

월간 실측 체인 (scripts/monthly_ai_measure.py, 핸드오버 §2-4)
질문 뱅크 러너 → 감성 판정 → 리포트 → supporter_builds.report.sentiment 를 한 명령으로
묶었다. 보정은 사람 게이트이므로 체인에 넣지 않았다. 질문 뱅크 러너가 아직
뷰성형외과 전용이라 다른 병원으로 넓히려면 러너를 먼저 매개변수화해야 한다.

버그 수정
- --summary 를 --calibration 없이 돌리면 §7 생성에서 죽던 문제. 핸드오버가 권장하는
  명령이 그대로 터졌다(--calibration 은 haewon 라벨을 지우므로 빼야 한다)
- 결과 jsonl 의 rubric 필드와 리포트 머리말의 기준표 버전이 v0.1 로 하드코딩돼 있었다

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 10:53:32 +09:00

292 lines
23 KiB
Python
Raw 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"
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).",
"- 재지 않는 것: 소비자 화면(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()