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>
110 lines
6.1 KiB
Python
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()
|