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>
292 lines
23 KiB
Python
292 lines
23 KiB
Python
#!/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()
|