o2o-infinith-demo/scripts/monthly_ai_measure.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

110 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""월간 AI 실측 체인: 질문 뱅크 러너 → 감성 판정 → 리포트 → supporter_builds.report.sentiment.
핸드오버 §2-4 의 "한 줄". 지금까지 사람이 네 개 명령을 순서대로 치던 것을 하나로 묶는다.
사람 게이트는 자동화하지 않는다. 보정(라벨링)은 여기 들어가지 않으며, 확정 여부는
calibration.confirmed 플래그로 리포트 카드에 그대로 전달한다.
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --skip-run # 기존 답변 재사용, 감성만
python3 scripts/monthly_ai_measure.py --clinic 뷰성형외과 --build-id <uuid> # Supabase 반영까지
한계: 질문 뱅크 러너(run_question_bank_openai.py)는 아직 뷰성형외과 전용이다
(scripts/question_bank_viewclinic.json 하드코딩). 다른 병원으로 넓히려면 러너를
먼저 병원별로 매개변수화해야 한다.
"""
import argparse, json, os, subprocess, sys, time, urllib.request, urllib.error, importlib.util
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "scripts", "out")
ENGINES = ("openai", "perplexity")
spec = importlib.util.spec_from_file_location("sqb", os.path.join(ROOT, "scripts", "sentiment_qb.py"))
sqb = importlib.util.module_from_spec(spec); spec.loader.exec_module(sqb)
def sh(argv):
print(" $ " + " ".join(argv), flush=True)
r = subprocess.run(argv, cwd=ROOT)
if r.returncode != 0: sys.exit(f"실패: {' '.join(argv)}")
def patch_supabase(build_id, sentiment):
"""supporter_builds.report 에 sentiment 키만 병합한다. 다른 단계가 쓴 키를 지우지 않는다."""
sqb.load_env()
url = os.environ.get("VITE_SUPABASE_URL"); key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
if not (url and key): sys.exit("VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 필요 (.env)")
H = {"apikey": key, "authorization": f"Bearer {key}", "content-type": "application/json"}
ep = f"{url}/rest/v1/supporter_builds?id=eq.{build_id}"
req = urllib.request.Request(ep + "&select=report", headers=H)
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
rows = json.loads(r.read().decode())
if not rows: sys.exit(f"supporter_builds 에 {build_id} 없음")
report = rows[0].get("report") or {}
report["sentiment"] = sentiment
body = json.dumps({"report": report, "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S")}).encode()
req = urllib.request.Request(ep, data=body, headers={**H, "prefer": "return=minimal"}, method="PATCH")
with urllib.request.urlopen(req, timeout=30, context=sqb.ssl_context()) as r:
print(f" supporter_builds.report.sentiment 갱신 (HTTP {r.status})")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--clinic", default="뷰성형외과")
ap.add_argument("--alias", default="뷰성형외과의원,뷰 성형외과,View Plastic Surgery,viewclinic")
ap.add_argument("--report", default="docs/reports/viewclinic/03_question_bank/Viewclinic_QB_Sentiment.md")
ap.add_argument("--build-id", help="주면 supporter_builds.report.sentiment 에 반영한다")
ap.add_argument("--skip-run", action="store_true", help="질문 뱅크 러너를 건너뛰고 기존 답변으로 감성만 다시 낸다")
ap.add_argument("--rejudge", action="store_true", help="기존 감성 판정을 지우고 다시 판정한다(기준표를 고친 뒤)")
a = ap.parse_args()
if not a.skip_run:
print("[1/3] 질문 뱅크 실측")
for e in ENGINES:
sh([sys.executable, "scripts/run_question_bank_openai.py", "--engine", e])
else:
print("[1/3] 질문 뱅크 실측 건너뜀 (--skip-run)")
print("[2/3] 감성 판정")
for e in ENGINES:
src = os.path.join(OUT, f"qb_{e}_results.jsonl")
dst = os.path.join(OUT, f"qb_sentiment_{e}.jsonl")
if not os.path.exists(src): sys.exit(f"답변 파일 없음: {src}")
if a.rejudge and os.path.exists(dst): os.remove(dst)
sh([sys.executable, "scripts/sentiment_qb.py", "--in", src, "--out", dst,
"--clinic", a.clinic, "--alias", a.alias])
print("[3/3] 리포트")
outs = [os.path.join(OUT, f"qb_sentiment_{e}.jsonl") for e in ENGINES]
sh([sys.executable, "scripts/sentiment_qb.py", "--summary", *outs,
"--report", a.report, "--clinic", a.clinic])
rows = [json.loads(l) for p in outs for l in open(p, encoding="utf-8") if l.strip()]
W = {"positive": 100, "neutral": 50, "mixed": 25, "negative": 0}
strong = [r for r in rows if r["sentiment"] != "not_mentioned" and not r.get("weak_mention")]
s = sqb.score(rows)
sentiment = {
"rubric": sqb.RUBRIC_VERSION, "model": rows[0].get("model"),
"measuredAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
"answers": s["answers"], "mentioned": s["mentioned"], "score": s["score"],
"scoreStrongOnly": round(sum(W[r["sentiment"]] for r in strong) / len(strong), 1) if strong else None,
"weakMentions": sum(1 for r in rows if r.get("weak_mention")),
"negativeRate": s["negative_rate"], "regulatoryRate": s["regulatory_rate"],
"recommendedRate": s["recommended_rate"],
"byEngine": {e: sqb.score([r for r in rows if r["engine"] == e]) for e in ENGINES},
# 리포트 카드는 이 플래그를 보고 "보정 전 참고값" 을 표시한다. 2차 보정 전에는 확정이 아니다.
"calibration": {"labeled": 29, "agreementPct": 93, "confirmed": False,
"note": "1차 보정 표본으로 규칙을 고쳤으므로 확정 근거가 아니다. 새 30건 2차 보정 필요."},
}
p = os.path.join(OUT, "sentiment_summary.json")
open(p, "w", encoding="utf-8").write(json.dumps(sentiment, ensure_ascii=False, indent=2))
print(f" 요약 → {p}")
print(f" 언급 {s['mentioned']}/{s['answers']} · 감성 점수 {s['score']} · 약한 언급 제외 {sentiment['scoreStrongOnly']}")
if a.build_id: patch_supabase(a.build_id, sentiment)
else: print(" --build-id 없음. Supabase 반영은 건너뛴다.")
if __name__ == "__main__":
main()